@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,34 @@
1
+ /**
2
+ * Numeric threshold heuristic for P->F residual layer
3
+ * Determines if a formal_ref points to a numeric parameter (auto-updatable)
4
+ * vs a correctness invariant (requires investigation)
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ const { parseFormalRef } = require('./extractFormalExpected.cjs');
10
+ const { extractFormalExpected } = require('./extractFormalExpected.cjs');
11
+
12
+ /**
13
+ * Check if a formal_ref points to a numeric threshold/parameter
14
+ * @param {string} formalRef - Formal reference string
15
+ * @param {object} [options]
16
+ * @param {string} [options.specDir] - Override spec directory (for testing)
17
+ * @returns {boolean} true if the ref points to a numeric value
18
+ */
19
+ function isNumericThreshold(formalRef, options = {}) {
20
+ const parsed = parseFormalRef(formalRef);
21
+ if (!parsed) return false;
22
+
23
+ // Requirements are text, not numeric thresholds
24
+ if (parsed.type === 'requirement') return false;
25
+
26
+ // Spec without param key = invariant reference
27
+ if (parsed.type === 'spec' && !parsed.param) return false;
28
+
29
+ // Try to extract the value and check if it is numeric
30
+ const value = extractFormalExpected(formalRef, options);
31
+ return typeof value === 'number';
32
+ }
33
+
34
+ module.exports = { isNumericThreshold };
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * issue-classifier.cjs
6
+ *
7
+ * Reads .planning/telemetry/report.json, ranks operational issues by severity,
8
+ * writes .planning/telemetry/pending-fixes.json with up to 3 prioritized issues.
9
+ *
10
+ * NEVER invokes Claude or any MCP tool. Pure disk I/O.
11
+ *
12
+ * Priority scoring:
13
+ * alwaysFailing server: 100 (token waste on every quorum round)
14
+ * circuitBreaker.active: 90 (oscillation active — blocks progress)
15
+ * hangCount > 5 for server: 80 (degrades quorum latency)
16
+ * quorumFailureRate > 0.5: 70 (majority of rounds have no quorum)
17
+ * slowServer with p95 > 30s: 60 (chronic latency)
18
+ * circuitBreaker.triggerCount > 3: 50 (repeated oscillation history)
19
+ *
20
+ * Usage:
21
+ * node bin/issue-classifier.cjs
22
+ */
23
+
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+
27
+ const PROJECT_DIR = process.cwd();
28
+ const TELEMETRY_DIR = path.join(PROJECT_DIR, '.planning', 'telemetry');
29
+ const REPORT_PATH = path.join(TELEMETRY_DIR, 'report.json');
30
+ const FIXES_PATH = path.join(TELEMETRY_DIR, 'pending-fixes.json');
31
+
32
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
33
+ function slug(str) {
34
+ return str.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
35
+ }
36
+
37
+ function writeEmpty(reason) {
38
+ if (reason) process.stderr.write('[issue-classifier] ' + reason + '\n');
39
+ fs.mkdirSync(TELEMETRY_DIR, { recursive: true });
40
+ const out = { generatedAt: new Date().toISOString(), issues: [] };
41
+ fs.writeFileSync(FIXES_PATH, JSON.stringify(out, null, 2), 'utf8');
42
+ console.log('[issue-classifier] pending-fixes.json written (0 issues)');
43
+ process.exit(0);
44
+ }
45
+
46
+ // ─── Load report.json ─────────────────────────────────────────────────────────
47
+ let report;
48
+ try {
49
+ if (!fs.existsSync(REPORT_PATH)) writeEmpty('report.json not found');
50
+ report = JSON.parse(fs.readFileSync(REPORT_PATH, 'utf8'));
51
+ } catch (err) {
52
+ writeEmpty('Failed to parse report.json: ' + err.message);
53
+ }
54
+
55
+ const mcp = report.mcp || {};
56
+ const quorum = report.quorum || {};
57
+ const circuitBreaker = report.circuitBreaker || {};
58
+
59
+ const issues = [];
60
+ const now = new Date().toISOString();
61
+
62
+ // ─── Rule 1: Always-failing servers (100 pts each) ────────────────────────────
63
+ for (const serverName of (mcp.alwaysFailing || [])) {
64
+ issues.push({
65
+ id: 'mcp-always-failing-' + slug(serverName),
66
+ priority: 100,
67
+ description: `MCP server "${serverName}" has never succeeded — it wastes tokens on every quorum round.`,
68
+ action: `Remove or disable "${serverName}" from ~/.claude.json mcpServers, or investigate its endpoint.`,
69
+ surfaced: false,
70
+ detectedAt: now,
71
+ });
72
+ }
73
+
74
+ // ─── Rule 2: Circuit breaker active (90 pts) ──────────────────────────────────
75
+ if (circuitBreaker.active === true) {
76
+ issues.push({
77
+ id: 'circuit-breaker-active',
78
+ priority: 90,
79
+ description: 'Circuit breaker is currently active — oscillation was detected and execution is paused.',
80
+ action: 'Run /qgsd:debug to diagnose the oscillation root cause, then run `npx qgsd --reset-breaker`.',
81
+ surfaced: false,
82
+ detectedAt: now,
83
+ });
84
+ }
85
+
86
+ // ─── Rule 3: High hang count (80 pts) ─────────────────────────────────────────
87
+ for (const [serverName, stats] of Object.entries(mcp.servers || {})) {
88
+ if ((stats.hangCount || 0) > 5) {
89
+ issues.push({
90
+ id: 'mcp-high-hangs-' + slug(serverName),
91
+ priority: 80,
92
+ description: `MCP server "${serverName}" hung ${stats.hangCount} times (>60s) — it degrades quorum latency significantly.`,
93
+ action: `Raise CLAUDE_MCP_TIMEOUT_MS or switch "${serverName}" to a faster provider in ~/.claude.json.`,
94
+ surfaced: false,
95
+ detectedAt: now,
96
+ });
97
+ }
98
+ }
99
+
100
+ // ─── Rule 4: High quorum failure rate (70 pts) ───────────────────────────────
101
+ if ((quorum.quorumFailureRate || 0) > 0.5 && (quorum.totalRounds || 0) > 0) {
102
+ const pct = Math.round(quorum.quorumFailureRate * 100);
103
+ issues.push({
104
+ id: 'quorum-high-failure-rate',
105
+ priority: 70,
106
+ description: `${pct}% of quorum rounds had no available external models — consensus quality is severely degraded.`,
107
+ action: 'Check provider API keys and quotas; run `node bin/check-mcp-health.cjs` to identify unavailable models.',
108
+ surfaced: false,
109
+ detectedAt: now,
110
+ });
111
+ }
112
+
113
+ // ─── Rule 5: Slow server p95 > 30s (60 pts) ──────────────────────────────────
114
+ for (const slow of (mcp.slowServers || [])) {
115
+ if ((slow.p95Ms || 0) > 30000) {
116
+ const p95s = (slow.p95Ms / 1000).toFixed(0);
117
+ issues.push({
118
+ id: 'mcp-slow-server-' + slug(slow.name),
119
+ priority: 60,
120
+ description: `MCP server "${slow.name}" has p95 latency of ${p95s}s — it chronically slows quorum rounds.`,
121
+ action: `Raise CLAUDE_MCP_TIMEOUT_MS or route "${slow.name}" via a faster provider endpoint.`,
122
+ surfaced: false,
123
+ detectedAt: now,
124
+ });
125
+ }
126
+ }
127
+
128
+ // ─── Rule 6: Repeated circuit breaker triggers (50 pts) ──────────────────────
129
+ if (!circuitBreaker.active && (circuitBreaker.triggerCount || 0) > 3) {
130
+ issues.push({
131
+ id: 'circuit-breaker-repeated-triggers',
132
+ priority: 50,
133
+ description: `Circuit breaker has triggered ${circuitBreaker.triggerCount} times — recurring oscillation pattern detected.`,
134
+ action: 'Run /qgsd:discuss-phase to review recent commit patterns; consider adding explicit done-criteria to plans.',
135
+ surfaced: false,
136
+ detectedAt: now,
137
+ });
138
+ }
139
+
140
+ // ─── Sort by priority desc, take top 3 ───────────────────────────────────────
141
+ issues.sort((a, b) => b.priority - a.priority);
142
+ const top3 = issues.slice(0, 3);
143
+
144
+ // ─── Write output ─────────────────────────────────────────────────────────────
145
+ fs.mkdirSync(TELEMETRY_DIR, { recursive: true });
146
+ const output = {
147
+ generatedAt: now,
148
+ issues: top3,
149
+ };
150
+ fs.writeFileSync(FIXES_PATH, JSON.stringify(output, null, 2), 'utf8');
151
+ console.log('[issue-classifier] pending-fixes.json written (' + top3.length + ' issues)');
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Levenshtein distance and similarity functions
3
+ * Used by the dedup engine for near-duplicate detection on debt entry titles
4
+ *
5
+ * Algorithm: Wagner-Fischer with two-row space optimization — O(m*n) time, O(min(m,n)) space
6
+ */
7
+
8
+ /**
9
+ * Compute Levenshtein edit distance between two strings
10
+ * @param {string} a - First string
11
+ * @param {string} b - Second string
12
+ * @returns {number} Integer edit distance
13
+ */
14
+ function levenshteinDistance(a, b) {
15
+ // Early exits
16
+ if (a === b) return 0;
17
+ if (a.length === 0) return b.length;
18
+ if (b.length === 0) return a.length;
19
+
20
+ // Ensure a is the shorter string for O(min(m,n)) space
21
+ if (a.length > b.length) {
22
+ [a, b] = [b, a];
23
+ }
24
+
25
+ const m = a.length;
26
+ const n = b.length;
27
+
28
+ // Two-row optimization: only keep previous and current row
29
+ let prev = new Array(m + 1);
30
+ let curr = new Array(m + 1);
31
+
32
+ // Initialize first row: distance from empty string to a[0..i]
33
+ for (let i = 0; i <= m; i++) {
34
+ prev[i] = i;
35
+ }
36
+
37
+ // Fill matrix row by row
38
+ for (let j = 1; j <= n; j++) {
39
+ curr[0] = j; // distance from b[0..j] to empty string
40
+
41
+ for (let i = 1; i <= m; i++) {
42
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
43
+ curr[i] = Math.min(
44
+ prev[i] + 1, // deletion
45
+ curr[i - 1] + 1, // insertion
46
+ prev[i - 1] + cost // substitution
47
+ );
48
+ }
49
+
50
+ // Swap rows
51
+ [prev, curr] = [curr, prev];
52
+ }
53
+
54
+ // Result is in prev (after swap) at position m
55
+ return prev[m];
56
+ }
57
+
58
+ /**
59
+ * Compute normalized Levenshtein similarity between two strings
60
+ * @param {string} a - First string
61
+ * @param {string} b - Second string
62
+ * @returns {number} Similarity score between 0.0 (completely different) and 1.0 (identical)
63
+ */
64
+ function levenshteinSimilarity(a, b) {
65
+ // Both empty = identical
66
+ if (a.length === 0 && b.length === 0) return 1.0;
67
+
68
+ const maxLen = Math.max(a.length, b.length);
69
+ const distance = levenshteinDistance(a, b);
70
+
71
+ return 1 - (distance / maxLen);
72
+ }
73
+
74
+ module.exports = { levenshteinDistance, levenshteinSimilarity };