@nforma.ai/nforma 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +1024 -0
- package/agents/qgsd-codebase-mapper.md +764 -0
- package/agents/qgsd-debugger.md +1201 -0
- package/agents/qgsd-executor.md +472 -0
- package/agents/qgsd-integration-checker.md +443 -0
- package/agents/qgsd-phase-researcher.md +502 -0
- package/agents/qgsd-plan-checker.md +643 -0
- package/agents/qgsd-planner.md +1182 -0
- package/agents/qgsd-project-researcher.md +621 -0
- package/agents/qgsd-quorum-orchestrator.md +628 -0
- package/agents/qgsd-quorum-slot-worker.md +41 -0
- package/agents/qgsd-quorum-synthesizer.md +133 -0
- package/agents/qgsd-quorum-test-worker.md +37 -0
- package/agents/qgsd-quorum-worker.md +161 -0
- package/agents/qgsd-research-synthesizer.md +239 -0
- package/agents/qgsd-roadmapper.md +660 -0
- package/agents/qgsd-verifier.md +628 -0
- package/bin/accept-debug-invariant.cjs +165 -0
- package/bin/account-manager.cjs +719 -0
- package/bin/aggregate-requirements.cjs +466 -0
- package/bin/analyze-assumptions.cjs +757 -0
- package/bin/analyze-state-space.cjs +921 -0
- package/bin/attribute-trace-divergence.cjs +150 -0
- package/bin/auth-drivers/gh-cli.cjs +93 -0
- package/bin/auth-drivers/index.cjs +46 -0
- package/bin/auth-drivers/pool.cjs +67 -0
- package/bin/auth-drivers/simple.cjs +95 -0
- package/bin/autoClosePtoF.cjs +110 -0
- package/bin/blessed-terminal.cjs +350 -0
- package/bin/build-phase-index.cjs +472 -0
- package/bin/call-quorum-slot.cjs +541 -0
- package/bin/ccr-secure-config.cjs +99 -0
- package/bin/ccr-secure-start.cjs +83 -0
- package/bin/check-bundled-sdks.cjs +177 -0
- package/bin/check-coverage-guard.cjs +112 -0
- package/bin/check-liveness-fairness.cjs +95 -0
- package/bin/check-mcp-health.cjs +123 -0
- package/bin/check-provider-health.cjs +395 -0
- package/bin/check-results-exit.cjs +24 -0
- package/bin/check-spec-sync.cjs +360 -0
- package/bin/check-trace-redaction.cjs +271 -0
- package/bin/check-trace-schema-drift.cjs +99 -0
- package/bin/compareDrift.cjs +21 -0
- package/bin/conformance-schema.cjs +12 -0
- package/bin/count-scenarios.cjs +420 -0
- package/bin/debt-dedup.cjs +144 -0
- package/bin/debt-ledger.cjs +61 -0
- package/bin/debt-retention.cjs +76 -0
- package/bin/debt-state-machine.cjs +80 -0
- package/bin/detect-coverage-gaps.cjs +204 -0
- package/bin/detect-project-intent.cjs +362 -0
- package/bin/export-prism-constants.cjs +164 -0
- package/bin/extract-annotations.cjs +633 -0
- package/bin/extractFormalExpected.cjs +104 -0
- package/bin/fingerprint-drift.cjs +24 -0
- package/bin/fingerprint-issue.cjs +46 -0
- package/bin/formal-core.cjs +519 -0
- package/bin/formal-ref-linker.cjs +141 -0
- package/bin/formal-test-sync.cjs +788 -0
- package/bin/generate-formal-specs.cjs +588 -0
- package/bin/generate-petri-net.cjs +397 -0
- package/bin/generate-phase-spec.cjs +249 -0
- package/bin/generate-proposed-changes.cjs +194 -0
- package/bin/generate-tla-cfg.cjs +122 -0
- package/bin/generate-traceability-matrix.cjs +701 -0
- package/bin/generate-triage-bundle.cjs +300 -0
- package/bin/gh-account-rotate.cjs +34 -0
- package/bin/initialize-model-registry.cjs +105 -0
- package/bin/install-formal-tools.cjs +382 -0
- package/bin/install.js +2424 -0
- package/bin/isNumericThreshold.cjs +34 -0
- package/bin/issue-classifier.cjs +151 -0
- package/bin/levenshtein.cjs +74 -0
- package/bin/lint-formal-models.cjs +580 -0
- package/bin/load-baseline-requirements.cjs +275 -0
- package/bin/manage-agents-core.cjs +815 -0
- package/bin/migrate-formal-dir.cjs +172 -0
- package/bin/migrate-planning.cjs +206 -0
- package/bin/migrate-to-slots.cjs +255 -0
- package/bin/nForma.cjs +2726 -0
- package/bin/observe-config.cjs +353 -0
- package/bin/observe-debt-writer.cjs +140 -0
- package/bin/observe-handler-grafana.cjs +128 -0
- package/bin/observe-handler-internal.cjs +301 -0
- package/bin/observe-handler-logstash.cjs +153 -0
- package/bin/observe-handler-prometheus.cjs +185 -0
- package/bin/observe-handlers.cjs +436 -0
- package/bin/observe-registry.cjs +131 -0
- package/bin/observe-render.cjs +168 -0
- package/bin/planning-paths.cjs +167 -0
- package/bin/polyrepo.cjs +560 -0
- package/bin/prism-priority.cjs +153 -0
- package/bin/probe-quorum-slots.cjs +167 -0
- package/bin/promote-model.cjs +225 -0
- package/bin/propose-debug-invariants.cjs +165 -0
- package/bin/providers.json +392 -0
- package/bin/pty-proxy.py +129 -0
- package/bin/qgsd-solve.cjs +2477 -0
- package/bin/quorum-consensus-gate.cjs +238 -0
- package/bin/quorum-formal-context.cjs +183 -0
- package/bin/quorum-slot-dispatch.cjs +934 -0
- package/bin/read-policy.cjs +60 -0
- package/bin/requirement-map.cjs +63 -0
- package/bin/requirements-core.cjs +247 -0
- package/bin/resolve-cli.cjs +101 -0
- package/bin/review-mcp-logs.cjs +294 -0
- package/bin/run-account-manager-tlc.cjs +188 -0
- package/bin/run-account-pool-alloy.cjs +158 -0
- package/bin/run-alloy.cjs +153 -0
- package/bin/run-audit-alloy.cjs +187 -0
- package/bin/run-breaker-tlc.cjs +181 -0
- package/bin/run-formal-check.cjs +395 -0
- package/bin/run-formal-verify.cjs +701 -0
- package/bin/run-installer-alloy.cjs +188 -0
- package/bin/run-oauth-rotation-prism.cjs +132 -0
- package/bin/run-oscillation-tlc.cjs +202 -0
- package/bin/run-phase-tlc.cjs +228 -0
- package/bin/run-prism.cjs +446 -0
- package/bin/run-protocol-tlc.cjs +201 -0
- package/bin/run-quorum-composition-alloy.cjs +155 -0
- package/bin/run-sensitivity-sweep.cjs +231 -0
- package/bin/run-stop-hook-tlc.cjs +188 -0
- package/bin/run-tlc.cjs +467 -0
- package/bin/run-transcript-alloy.cjs +173 -0
- package/bin/run-uppaal.cjs +264 -0
- package/bin/secrets.cjs +134 -0
- package/bin/sensitivity-report.cjs +219 -0
- package/bin/sensitivity-sweep-feedback.cjs +194 -0
- package/bin/set-secret.cjs +29 -0
- package/bin/setup-telemetry-cron.sh +36 -0
- package/bin/sweepPtoF.cjs +63 -0
- package/bin/sync-baseline-requirements.cjs +290 -0
- package/bin/task-envelope.cjs +360 -0
- package/bin/telemetry-collector.cjs +229 -0
- package/bin/unified-mcp-server.mjs +735 -0
- package/bin/update-agents.cjs +369 -0
- package/bin/update-scoreboard.cjs +1134 -0
- package/bin/validate-debt-entry.cjs +207 -0
- package/bin/validate-invariant.cjs +419 -0
- package/bin/validate-memory.cjs +389 -0
- package/bin/validate-requirements-haiku.cjs +435 -0
- package/bin/validate-traces.cjs +438 -0
- package/bin/verify-formal-results.cjs +124 -0
- package/bin/verify-quorum-health.cjs +273 -0
- package/bin/write-check-result.cjs +106 -0
- package/bin/xstate-to-tla.cjs +483 -0
- package/bin/xstate-trace-walker.cjs +205 -0
- package/commands/qgsd/add-phase.md +43 -0
- package/commands/qgsd/add-requirement.md +24 -0
- package/commands/qgsd/add-todo.md +47 -0
- package/commands/qgsd/audit-milestone.md +37 -0
- package/commands/qgsd/check-todos.md +45 -0
- package/commands/qgsd/cleanup.md +18 -0
- package/commands/qgsd/close-formal-gaps.md +33 -0
- package/commands/qgsd/complete-milestone.md +136 -0
- package/commands/qgsd/debug.md +166 -0
- package/commands/qgsd/discuss-phase.md +83 -0
- package/commands/qgsd/execute-phase.md +117 -0
- package/commands/qgsd/fix-tests.md +27 -0
- package/commands/qgsd/formal-test-sync.md +32 -0
- package/commands/qgsd/health.md +22 -0
- package/commands/qgsd/help.md +22 -0
- package/commands/qgsd/insert-phase.md +32 -0
- package/commands/qgsd/join-discord.md +18 -0
- package/commands/qgsd/list-phase-assumptions.md +46 -0
- package/commands/qgsd/map-codebase.md +71 -0
- package/commands/qgsd/map-requirements.md +20 -0
- package/commands/qgsd/mcp-restart.md +176 -0
- package/commands/qgsd/mcp-set-model.md +134 -0
- package/commands/qgsd/mcp-setup.md +1371 -0
- package/commands/qgsd/mcp-status.md +274 -0
- package/commands/qgsd/mcp-update.md +238 -0
- package/commands/qgsd/new-milestone.md +44 -0
- package/commands/qgsd/new-project.md +42 -0
- package/commands/qgsd/observe.md +260 -0
- package/commands/qgsd/pause-work.md +38 -0
- package/commands/qgsd/plan-milestone-gaps.md +34 -0
- package/commands/qgsd/plan-phase.md +44 -0
- package/commands/qgsd/polyrepo.md +50 -0
- package/commands/qgsd/progress.md +24 -0
- package/commands/qgsd/queue.md +54 -0
- package/commands/qgsd/quick.md +133 -0
- package/commands/qgsd/quorum-test.md +275 -0
- package/commands/qgsd/quorum.md +707 -0
- package/commands/qgsd/reapply-patches.md +110 -0
- package/commands/qgsd/remove-phase.md +31 -0
- package/commands/qgsd/research-phase.md +189 -0
- package/commands/qgsd/resume-work.md +40 -0
- package/commands/qgsd/set-profile.md +34 -0
- package/commands/qgsd/settings.md +39 -0
- package/commands/qgsd/solve.md +565 -0
- package/commands/qgsd/sync-baselines.md +119 -0
- package/commands/qgsd/triage.md +233 -0
- package/commands/qgsd/update.md +37 -0
- package/commands/qgsd/verify-work.md +38 -0
- package/hooks/dist/config-loader.js +297 -0
- package/hooks/dist/conformance-schema.cjs +12 -0
- package/hooks/dist/gsd-context-monitor.js +64 -0
- package/hooks/dist/qgsd-check-update.js +62 -0
- package/hooks/dist/qgsd-circuit-breaker.js +682 -0
- package/hooks/dist/qgsd-precompact.js +156 -0
- package/hooks/dist/qgsd-prompt.js +653 -0
- package/hooks/dist/qgsd-session-start.js +122 -0
- package/hooks/dist/qgsd-slot-correlator.js +58 -0
- package/hooks/dist/qgsd-spec-regen.js +86 -0
- package/hooks/dist/qgsd-statusline.js +91 -0
- package/hooks/dist/qgsd-stop.js +553 -0
- package/hooks/dist/qgsd-token-collector.js +133 -0
- package/hooks/dist/unified-mcp-server.mjs +669 -0
- package/package.json +95 -0
- package/scripts/build-hooks.js +46 -0
- package/scripts/postinstall.js +48 -0
- package/scripts/secret-audit.sh +45 -0
- package/templates/qgsd.json +49 -0
|
@@ -0,0 +1,2477 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// bin/qgsd-solve.cjs
|
|
4
|
+
// Consistency solver orchestrator: sweeps Requirements->Formal->Tests->Code->Docs,
|
|
5
|
+
// computes a residual vector per layer transition, and auto-closes gaps.
|
|
6
|
+
//
|
|
7
|
+
// Layer transitions (8 forward + 3 reverse):
|
|
8
|
+
// R->F: Requirements without formal model coverage
|
|
9
|
+
// F->T: Formal invariants without test backing
|
|
10
|
+
// C->F: Code constants diverging from formal specs
|
|
11
|
+
// T->C: Failing unit tests
|
|
12
|
+
// F->C: Failing formal verification checks
|
|
13
|
+
// R->D: Requirements not documented in developer docs
|
|
14
|
+
// D->C: Stale structural claims in docs (dead file paths, missing CLI commands, absent dependencies)
|
|
15
|
+
// P->F: Acknowledged production debt entries diverging from formal model thresholds
|
|
16
|
+
// Reverse (discovery-only, human-gated):
|
|
17
|
+
// C->R: Source modules in bin/hooks/ with no requirement tracing
|
|
18
|
+
// T->R: Test files with no @req annotation or formal-test-sync mapping
|
|
19
|
+
// D->R: Doc capability claims without requirement backing
|
|
20
|
+
//
|
|
21
|
+
// Usage:
|
|
22
|
+
// node bin/qgsd-solve.cjs # full sync, up to 3 iterations
|
|
23
|
+
// node bin/qgsd-solve.cjs --report-only # single sweep, no mutations
|
|
24
|
+
// node bin/qgsd-solve.cjs --max-iterations=1
|
|
25
|
+
// node bin/qgsd-solve.cjs --json # machine-readable output
|
|
26
|
+
// node bin/qgsd-solve.cjs --verbose # pipe child stderr to parent stderr
|
|
27
|
+
// node bin/qgsd-solve.cjs --fast # skip F->C and T->C layers for sub-second iteration
|
|
28
|
+
//
|
|
29
|
+
// Requirements: QUICK-140
|
|
30
|
+
|
|
31
|
+
const fs = require('fs');
|
|
32
|
+
const path = require('path');
|
|
33
|
+
const { spawnSync } = require('child_process');
|
|
34
|
+
|
|
35
|
+
const TAG = '[qgsd-solve]';
|
|
36
|
+
let ROOT = process.cwd();
|
|
37
|
+
const SCRIPT_DIR = __dirname;
|
|
38
|
+
const DEFAULT_MAX_ITERATIONS = 3;
|
|
39
|
+
|
|
40
|
+
// ── CLI flags ────────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
const args = process.argv.slice(2);
|
|
43
|
+
const reportOnly = args.includes('--report-only');
|
|
44
|
+
const jsonMode = args.includes('--json');
|
|
45
|
+
const verboseMode = args.includes('--verbose');
|
|
46
|
+
const fastMode = args.includes('--fast');
|
|
47
|
+
|
|
48
|
+
// Parse --project-root (overrides CWD-based ROOT for cross-repo usage)
|
|
49
|
+
for (const arg of args) {
|
|
50
|
+
if (arg.startsWith('--project-root=')) {
|
|
51
|
+
ROOT = path.resolve(arg.slice('--project-root='.length));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let maxIterations = DEFAULT_MAX_ITERATIONS;
|
|
56
|
+
for (const arg of args) {
|
|
57
|
+
if (arg.startsWith('--max-iterations=')) {
|
|
58
|
+
const val = parseInt(arg.slice('--max-iterations='.length), 10);
|
|
59
|
+
if (!isNaN(val) && val >= 1 && val <= 10) {
|
|
60
|
+
maxIterations = val;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Helper: spawnTool ────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Spawns a child process with error handling and optional stderr piping.
|
|
69
|
+
* Returns { ok: boolean, stdout: string, stderr: string }.
|
|
70
|
+
*/
|
|
71
|
+
function spawnTool(script, args, opts = {}) {
|
|
72
|
+
const scriptPath = path.join(SCRIPT_DIR, path.basename(script));
|
|
73
|
+
// Auto-forward --project-root to child script
|
|
74
|
+
const childArgs = [...args];
|
|
75
|
+
if (!childArgs.some(a => a.startsWith('--project-root='))) {
|
|
76
|
+
childArgs.push('--project-root=' + ROOT);
|
|
77
|
+
}
|
|
78
|
+
const defaultStdio = verboseMode ? ['pipe', 'pipe', 'inherit'] : 'pipe';
|
|
79
|
+
const spawnOpts = {
|
|
80
|
+
encoding: 'utf8',
|
|
81
|
+
cwd: ROOT,
|
|
82
|
+
timeout: opts.timeout || 120000,
|
|
83
|
+
stdio: opts.stdio || defaultStdio,
|
|
84
|
+
maxBuffer: opts.maxBuffer || 10 * 1024 * 1024,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const result = spawnSync(process.execPath, [scriptPath, ...childArgs], spawnOpts);
|
|
89
|
+
if (result.error) {
|
|
90
|
+
return {
|
|
91
|
+
ok: false,
|
|
92
|
+
stdout: '',
|
|
93
|
+
stderr: result.error.message,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
ok: result.status === 0,
|
|
98
|
+
stdout: result.stdout || '',
|
|
99
|
+
stderr: result.stderr || '',
|
|
100
|
+
};
|
|
101
|
+
} catch (err) {
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
stdout: '',
|
|
105
|
+
stderr: err.message,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── P->F layer imports ──────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
const { sweepPtoF } = require('./sweepPtoF.cjs');
|
|
113
|
+
const { autoClosePtoF } = require('./autoClosePtoF.cjs');
|
|
114
|
+
|
|
115
|
+
// ── Doc discovery helpers ────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Simple wildcard matcher for patterns like "**\/*.md" and "README.md".
|
|
119
|
+
* Supports: ** (any path segment), * (any filename segment), literal match.
|
|
120
|
+
*/
|
|
121
|
+
function matchWildcard(pattern, filePath) {
|
|
122
|
+
const normPath = filePath.replace(/\\/g, '/');
|
|
123
|
+
const normPattern = pattern.replace(/\\/g, '/');
|
|
124
|
+
|
|
125
|
+
if (!normPattern.includes('*')) {
|
|
126
|
+
return normPath === normPattern || normPath.endsWith('/' + normPattern);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let regex = normPattern
|
|
130
|
+
.replace(/\./g, '\\.')
|
|
131
|
+
.replace(/\*\*\//g, '(.+/)?')
|
|
132
|
+
.replace(/\*/g, '[^/]*');
|
|
133
|
+
regex = '^(' + regex + ')$';
|
|
134
|
+
|
|
135
|
+
return new RegExp(regex).test(normPath);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Recursively walk a directory, returning files up to maxDepth levels.
|
|
140
|
+
*/
|
|
141
|
+
function walkDir(dir, maxDepth, currentDepth) {
|
|
142
|
+
if (currentDepth === undefined) currentDepth = 0;
|
|
143
|
+
if (maxDepth === undefined) maxDepth = 10;
|
|
144
|
+
if (currentDepth > maxDepth) return [];
|
|
145
|
+
|
|
146
|
+
const results = [];
|
|
147
|
+
let entries;
|
|
148
|
+
try {
|
|
149
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
150
|
+
} catch (e) {
|
|
151
|
+
return results;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
for (const entry of entries) {
|
|
155
|
+
const fullPath = path.join(dir, entry.name);
|
|
156
|
+
if (entry.isDirectory()) {
|
|
157
|
+
if (entry.name === 'node_modules' || entry.name === '.git') continue;
|
|
158
|
+
const sub = walkDir(fullPath, maxDepth, currentDepth + 1);
|
|
159
|
+
for (const s of sub) results.push(s);
|
|
160
|
+
} else if (entry.isFile()) {
|
|
161
|
+
results.push(fullPath);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return results;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Detect uninitialized git submodules that overlap with doc paths.
|
|
169
|
+
* Returns array of { submodule, docKey } warnings.
|
|
170
|
+
*/
|
|
171
|
+
function detectUninitializedSubmodules(docPaths) {
|
|
172
|
+
const gitmodulesPath = path.join(ROOT, '.gitmodules');
|
|
173
|
+
if (!fs.existsSync(gitmodulesPath)) return [];
|
|
174
|
+
|
|
175
|
+
const warnings = [];
|
|
176
|
+
try {
|
|
177
|
+
const content = fs.readFileSync(gitmodulesPath, 'utf8');
|
|
178
|
+
const submodules = [];
|
|
179
|
+
const re = /\[submodule\s+"([^"]+)"\][\s\S]*?path\s*=\s*(.+)/g;
|
|
180
|
+
let m;
|
|
181
|
+
while ((m = re.exec(content)) !== null) {
|
|
182
|
+
submodules.push({ name: m[1].trim(), path: m[2].trim() });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
for (const sub of submodules) {
|
|
186
|
+
const subAbsPath = path.join(ROOT, sub.path);
|
|
187
|
+
const isInitialized = fs.existsSync(subAbsPath) &&
|
|
188
|
+
fs.readdirSync(subAbsPath).length > 0;
|
|
189
|
+
|
|
190
|
+
for (const [docKey, docPath] of Object.entries(docPaths)) {
|
|
191
|
+
const docNorm = docPath.replace(/\/$/, '');
|
|
192
|
+
if (sub.path === docNorm || sub.path.startsWith(docNorm + '/') || docNorm.startsWith(sub.path + '/')) {
|
|
193
|
+
if (!isInitialized) {
|
|
194
|
+
warnings.push({ submodule: sub.path, docKey, name: sub.name });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
} catch (e) {
|
|
200
|
+
// .gitmodules parse error — fail-open
|
|
201
|
+
}
|
|
202
|
+
return warnings;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Discover documentation files based on:
|
|
207
|
+
* 1. .planning/polyrepo.json docs field (preferred — knows user vs developer vs examples)
|
|
208
|
+
* 2. .planning/config.json docs_paths (legacy)
|
|
209
|
+
* 3. Fallback patterns: README.md, docs/ (recursive .md)
|
|
210
|
+
* Returns array of { absPath, category } where category is 'user'|'developer'|'examples'|'unknown'.
|
|
211
|
+
*/
|
|
212
|
+
function discoverDocFiles() {
|
|
213
|
+
let docPatterns = [
|
|
214
|
+
{ pattern: 'README.md', category: 'user' },
|
|
215
|
+
{ pattern: 'docs/**/*.md', category: 'unknown' },
|
|
216
|
+
];
|
|
217
|
+
let markerDocs = null;
|
|
218
|
+
|
|
219
|
+
// Prefer polyrepo marker docs field
|
|
220
|
+
const markerPath = path.join(ROOT, '.planning', 'polyrepo.json');
|
|
221
|
+
try {
|
|
222
|
+
const marker = JSON.parse(fs.readFileSync(markerPath, 'utf8'));
|
|
223
|
+
if (marker.docs && typeof marker.docs === 'object') {
|
|
224
|
+
markerDocs = marker.docs;
|
|
225
|
+
const patterns = [];
|
|
226
|
+
for (const [key, docPath] of Object.entries(marker.docs)) {
|
|
227
|
+
if (typeof docPath !== 'string') continue;
|
|
228
|
+
if (docPath.endsWith('/')) {
|
|
229
|
+
patterns.push({ pattern: docPath + '**/*.md', category: key });
|
|
230
|
+
} else {
|
|
231
|
+
patterns.push({ pattern: docPath, category: key });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (patterns.length > 0) {
|
|
235
|
+
patterns.unshift({ pattern: 'README.md', category: 'user' });
|
|
236
|
+
// Sort: exact paths first, then deeper globs before shallower ones
|
|
237
|
+
patterns.sort((a, b) => {
|
|
238
|
+
const aGlob = a.pattern.includes('*') ? 1 : 0;
|
|
239
|
+
const bGlob = b.pattern.includes('*') ? 1 : 0;
|
|
240
|
+
if (aGlob !== bGlob) return aGlob - bGlob; // exact paths first
|
|
241
|
+
const aDepth = a.pattern.split('/').length;
|
|
242
|
+
const bDepth = b.pattern.split('/').length;
|
|
243
|
+
return bDepth - aDepth; // deeper globs first
|
|
244
|
+
});
|
|
245
|
+
docPatterns = patterns;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Check for uninitialized submodules overlapping doc paths
|
|
249
|
+
const subWarnings = detectUninitializedSubmodules(marker.docs);
|
|
250
|
+
for (const w of subWarnings) {
|
|
251
|
+
console.error(
|
|
252
|
+
`[qgsd-solve] WARNING: docs.${w.docKey} overlaps submodule "${w.name}" ` +
|
|
253
|
+
`(${w.submodule}) which is not initialized. Run: git submodule update --init ${w.submodule}`
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
} catch (e) {
|
|
258
|
+
// No marker or malformed — fall through to config.json
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Fall back to config.json docs_paths if marker didn't provide patterns
|
|
262
|
+
if (!markerDocs) {
|
|
263
|
+
const configPath = path.join(ROOT, '.planning', 'config.json');
|
|
264
|
+
try {
|
|
265
|
+
const configData = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
266
|
+
if (Array.isArray(configData.docs_paths) && configData.docs_paths.length > 0) {
|
|
267
|
+
docPatterns = configData.docs_paths.map(p => ({ pattern: p, category: 'unknown' }));
|
|
268
|
+
}
|
|
269
|
+
} catch (e) {
|
|
270
|
+
// Use defaults
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const found = new Map();
|
|
275
|
+
|
|
276
|
+
for (const { pattern, category } of docPatterns) {
|
|
277
|
+
if (pattern.includes('*')) {
|
|
278
|
+
const parts = pattern.replace(/\\/g, '/').split('/');
|
|
279
|
+
let baseDir = ROOT;
|
|
280
|
+
for (const part of parts) {
|
|
281
|
+
if (part.includes('*')) break;
|
|
282
|
+
baseDir = path.join(baseDir, part);
|
|
283
|
+
}
|
|
284
|
+
if (!fs.existsSync(baseDir)) continue;
|
|
285
|
+
|
|
286
|
+
const allFiles = walkDir(baseDir, 10, 0);
|
|
287
|
+
for (const f of allFiles) {
|
|
288
|
+
const relative = path.relative(ROOT, f).replace(/\\/g, '/');
|
|
289
|
+
if (matchWildcard(pattern, relative)) {
|
|
290
|
+
if (!found.has(f)) found.set(f, category);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
} else {
|
|
294
|
+
const fullPath = path.join(ROOT, pattern);
|
|
295
|
+
if (fs.existsSync(fullPath)) {
|
|
296
|
+
if (!found.has(fullPath)) found.set(fullPath, category);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return Array.from(found.entries()).map(([absPath, category]) => ({ absPath, category }));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ── Keyword extraction ──────────────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
const STOPWORDS = new Set([
|
|
307
|
+
'the', 'this', 'that', 'with', 'from', 'into', 'each', 'when',
|
|
308
|
+
'must', 'should', 'will', 'have', 'been', 'does', 'also', 'used',
|
|
309
|
+
'using', 'only', 'such', 'both', 'than', 'some', 'more', 'most',
|
|
310
|
+
'very', 'other', 'about', 'which', 'their', 'would', 'could',
|
|
311
|
+
'there', 'where', 'these', 'those', 'after', 'before', 'being',
|
|
312
|
+
'through', 'during', 'between', 'without', 'within', 'against',
|
|
313
|
+
'under', 'above', 'below',
|
|
314
|
+
]);
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Extract keywords from text for fuzzy matching.
|
|
318
|
+
* Strips backtick-wrapped fragments, stopwords, and short tokens.
|
|
319
|
+
* Returns unique lowercase tokens.
|
|
320
|
+
*/
|
|
321
|
+
function extractKeywords(text) {
|
|
322
|
+
let cleaned = text.replace(/`[^`]*`/g, ' ');
|
|
323
|
+
const tokens = cleaned.split(/[\s,;:.()\[\]{}<>!?"']+/);
|
|
324
|
+
|
|
325
|
+
const seen = new Set();
|
|
326
|
+
const result = [];
|
|
327
|
+
|
|
328
|
+
for (const raw of tokens) {
|
|
329
|
+
const token = raw.toLowerCase().replace(/[^a-z0-9-]/g, '');
|
|
330
|
+
if (token.length < 4) continue;
|
|
331
|
+
if (STOPWORDS.has(token)) continue;
|
|
332
|
+
if (seen.has(token)) continue;
|
|
333
|
+
seen.add(token);
|
|
334
|
+
result.push(token);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return result;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ── Structural claims extraction ─────────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Extract structural claims (file paths, CLI commands, dependencies) from doc content.
|
|
344
|
+
* Skips fenced code blocks, Example/Template headings, template variables,
|
|
345
|
+
* home directory paths, and code expressions.
|
|
346
|
+
* Returns array of { line, type, value, doc_file }.
|
|
347
|
+
*/
|
|
348
|
+
function extractStructuralClaims(docContent, filePath) {
|
|
349
|
+
const lines = docContent.split('\n');
|
|
350
|
+
const claims = [];
|
|
351
|
+
let inFencedBlock = false;
|
|
352
|
+
let skipSection = false;
|
|
353
|
+
|
|
354
|
+
for (let i = 0; i < lines.length; i++) {
|
|
355
|
+
const line = lines[i];
|
|
356
|
+
|
|
357
|
+
// Track fenced code blocks
|
|
358
|
+
if (line.trimStart().startsWith('```')) {
|
|
359
|
+
inFencedBlock = !inFencedBlock;
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
if (inFencedBlock) continue;
|
|
363
|
+
|
|
364
|
+
// Track headings - skip Example/Template sections
|
|
365
|
+
const headingMatch = line.match(/^#{1,6}\s+(.+)/);
|
|
366
|
+
if (headingMatch) {
|
|
367
|
+
const headingText = headingMatch[1].toLowerCase();
|
|
368
|
+
skipSection = headingText.includes('example') || headingText.includes('template');
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
if (skipSection) continue;
|
|
372
|
+
|
|
373
|
+
// Find backtick-wrapped values
|
|
374
|
+
const backtickPattern = /`([^`]+)`/g;
|
|
375
|
+
let match;
|
|
376
|
+
while ((match = backtickPattern.exec(line)) !== null) {
|
|
377
|
+
const value = match[1].trim();
|
|
378
|
+
if (value.length < 4) continue;
|
|
379
|
+
|
|
380
|
+
// Filter: template variables
|
|
381
|
+
if (value.includes('{') || value.includes('}')) continue;
|
|
382
|
+
|
|
383
|
+
// Filter: home directory paths
|
|
384
|
+
if (value.startsWith('~/')) continue;
|
|
385
|
+
|
|
386
|
+
// Filter: code expressions (operators)
|
|
387
|
+
if (/[+=>]|&&|\|\|/.test(value)) continue;
|
|
388
|
+
|
|
389
|
+
// Classify the claim
|
|
390
|
+
let type = null;
|
|
391
|
+
|
|
392
|
+
// CLI command: starts with node, npx, npm
|
|
393
|
+
if (/^(node|npx|npm)\s+/.test(value)) {
|
|
394
|
+
type = 'cli_command';
|
|
395
|
+
}
|
|
396
|
+
// File path: contains / with extension, or starts with .
|
|
397
|
+
else if ((value.includes('/') && /\.\w+$/.test(value)) || (value.startsWith('.') && /\.\w+$/.test(value))) {
|
|
398
|
+
if (value.startsWith('/')) continue;
|
|
399
|
+
type = 'file_path';
|
|
400
|
+
}
|
|
401
|
+
// Dependency: npm-style package name (lowercase, optional @scope/)
|
|
402
|
+
else if (/^(@[a-z0-9-]+\/)?[a-z0-9][a-z0-9._-]*$/.test(value) && !value.includes('/')) {
|
|
403
|
+
type = 'dependency';
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (type) {
|
|
407
|
+
claims.push({
|
|
408
|
+
line: i + 1,
|
|
409
|
+
type: type,
|
|
410
|
+
value: value,
|
|
411
|
+
doc_file: filePath,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return claims;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ── Preflight bootstrap ──────────────────────────────────────────────────────
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Auto-creates .planning/formal/ subdirectories if missing on first run.
|
|
424
|
+
* Called at the top of main() before the iteration loop.
|
|
425
|
+
*/
|
|
426
|
+
function preflight() {
|
|
427
|
+
const formalDir = path.join(ROOT, '.planning', 'formal');
|
|
428
|
+
const subdirs = ['tla', 'alloy', 'generated-stubs'];
|
|
429
|
+
let created = false;
|
|
430
|
+
|
|
431
|
+
if (!fs.existsSync(formalDir)) {
|
|
432
|
+
fs.mkdirSync(formalDir, { recursive: true });
|
|
433
|
+
created = true;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
for (const sub of subdirs) {
|
|
437
|
+
const subPath = path.join(formalDir, sub);
|
|
438
|
+
if (!fs.existsSync(subPath)) {
|
|
439
|
+
fs.mkdirSync(subPath, { recursive: true });
|
|
440
|
+
created = true;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Seed model-registry.json if missing
|
|
445
|
+
const registryPath = path.join(formalDir, 'model-registry.json');
|
|
446
|
+
if (!fs.existsSync(registryPath)) {
|
|
447
|
+
try {
|
|
448
|
+
fs.writeFileSync(registryPath, JSON.stringify({ models: [], search_dirs: [] }, null, 2) + '\n');
|
|
449
|
+
created = true;
|
|
450
|
+
} catch (e) {
|
|
451
|
+
// fail-open
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (created) {
|
|
456
|
+
process.stderr.write(TAG + ' Bootstrapped formal infrastructure\n');
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ── Layer transition sweeps ──────────────────────────────────────────────────
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Triage requirements by formalizability.
|
|
464
|
+
* Scores each requirement into HIGH/MEDIUM/LOW/SKIP priority buckets.
|
|
465
|
+
* @param {Array} requirements - Array of requirement objects with text/description fields
|
|
466
|
+
* @returns {{ high: string[], medium: string[], low: string[], skip: string[] }}
|
|
467
|
+
*/
|
|
468
|
+
function triageRequirements(requirements) {
|
|
469
|
+
const HIGH_KEYWORDS = ['shall', 'must', 'invariant', 'constraint'];
|
|
470
|
+
const MEDIUM_KEYWORDS = ['should', 'verify', 'ensure', 'validate', 'check'];
|
|
471
|
+
const LOW_KEYWORDS = ['may', 'could', 'consider', 'nice-to-have'];
|
|
472
|
+
const SKIP_KEYWORDS = ['deferred', 'out-of-scope', 'deprecated'];
|
|
473
|
+
|
|
474
|
+
const result = { high: [], medium: [], low: [], skip: [] };
|
|
475
|
+
|
|
476
|
+
for (const req of requirements) {
|
|
477
|
+
const id = req.id || req.requirement_id || '';
|
|
478
|
+
if (!id) continue;
|
|
479
|
+
|
|
480
|
+
const text = (req.text || req.description || '').toLowerCase();
|
|
481
|
+
|
|
482
|
+
// Check formalizability field override
|
|
483
|
+
if (req.formalizability === 'high') {
|
|
484
|
+
result.high.push(id);
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Priority order: SKIP > HIGH > MEDIUM > LOW
|
|
489
|
+
if (SKIP_KEYWORDS.some(kw => new RegExp('\\b' + kw + '\\b', 'i').test(text))) {
|
|
490
|
+
result.skip.push(id);
|
|
491
|
+
} else if (HIGH_KEYWORDS.some(kw => new RegExp('\\b' + kw + '\\b', 'i').test(text))) {
|
|
492
|
+
result.high.push(id);
|
|
493
|
+
} else if (MEDIUM_KEYWORDS.some(kw => new RegExp('\\b' + kw + '\\b', 'i').test(text))) {
|
|
494
|
+
result.medium.push(id);
|
|
495
|
+
} else if (LOW_KEYWORDS.some(kw => new RegExp('\\b' + kw + '\\b', 'i').test(text))) {
|
|
496
|
+
result.low.push(id);
|
|
497
|
+
} else {
|
|
498
|
+
result.low.push(id); // default to low if no keywords match
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return result;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* R->F: Requirements to Formal coverage.
|
|
507
|
+
* Returns { residual: N, detail: {...} }
|
|
508
|
+
*/
|
|
509
|
+
function sweepRtoF() {
|
|
510
|
+
const result = spawnTool('bin/generate-traceability-matrix.cjs', [
|
|
511
|
+
'--json',
|
|
512
|
+
'--quiet',
|
|
513
|
+
]);
|
|
514
|
+
|
|
515
|
+
if (!result.ok) {
|
|
516
|
+
return {
|
|
517
|
+
residual: -1,
|
|
518
|
+
detail: {
|
|
519
|
+
error: result.stderr || 'generate-traceability-matrix.cjs failed',
|
|
520
|
+
},
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
try {
|
|
525
|
+
const matrix = JSON.parse(result.stdout);
|
|
526
|
+
const coverage = matrix.coverage_summary || {};
|
|
527
|
+
const uncovered = coverage.uncovered_requirements || [];
|
|
528
|
+
const total = coverage.total_requirements || 0;
|
|
529
|
+
const covered = coverage.covered_requirements || 0;
|
|
530
|
+
const percentage = total > 0 ? ((covered / total) * 100).toFixed(1) : 0;
|
|
531
|
+
|
|
532
|
+
// Triage uncovered requirements by formalizability
|
|
533
|
+
// Load full requirements to get text for keyword matching
|
|
534
|
+
const reqPath = path.join(ROOT, '.planning', 'formal', 'requirements.json');
|
|
535
|
+
let uncoveredReqs = [];
|
|
536
|
+
try {
|
|
537
|
+
const reqData = JSON.parse(fs.readFileSync(reqPath, 'utf8'));
|
|
538
|
+
let allReqs = [];
|
|
539
|
+
if (Array.isArray(reqData)) {
|
|
540
|
+
allReqs = reqData;
|
|
541
|
+
} else if (reqData.requirements && Array.isArray(reqData.requirements)) {
|
|
542
|
+
allReqs = reqData.requirements;
|
|
543
|
+
} else if (reqData.groups && Array.isArray(reqData.groups)) {
|
|
544
|
+
for (const group of reqData.groups) {
|
|
545
|
+
if (group.requirements && Array.isArray(group.requirements)) {
|
|
546
|
+
for (const r of group.requirements) allReqs.push(r);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
const uncoveredSet = new Set(uncovered);
|
|
551
|
+
uncoveredReqs = allReqs.filter(r => uncoveredSet.has(r.id || r.requirement_id || ''));
|
|
552
|
+
} catch (e) {
|
|
553
|
+
// Can't load requirements — skip triage
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const triage = triageRequirements(uncoveredReqs);
|
|
557
|
+
const highIds = triage.high;
|
|
558
|
+
const mediumIds = triage.medium;
|
|
559
|
+
const priority_batch = highIds.concat(mediumIds).slice(0, 15);
|
|
560
|
+
|
|
561
|
+
return {
|
|
562
|
+
residual: uncovered.length,
|
|
563
|
+
detail: {
|
|
564
|
+
uncovered_requirements: uncovered,
|
|
565
|
+
total: total,
|
|
566
|
+
covered: covered,
|
|
567
|
+
percentage: percentage,
|
|
568
|
+
triage: {
|
|
569
|
+
high: triage.high.length,
|
|
570
|
+
medium: triage.medium.length,
|
|
571
|
+
low: triage.low.length,
|
|
572
|
+
skip: triage.skip.length,
|
|
573
|
+
},
|
|
574
|
+
priority_batch: priority_batch,
|
|
575
|
+
},
|
|
576
|
+
};
|
|
577
|
+
} catch (err) {
|
|
578
|
+
return {
|
|
579
|
+
residual: -1,
|
|
580
|
+
detail: { error: 'Failed to parse traceability matrix: ' + err.message },
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Cache for formal-test-sync.cjs --json --report-only result.
|
|
587
|
+
*/
|
|
588
|
+
let formalTestSyncCache = null;
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Helper to load and cache formal-test-sync result.
|
|
592
|
+
*/
|
|
593
|
+
function loadFormalTestSync() {
|
|
594
|
+
if (formalTestSyncCache) return formalTestSyncCache;
|
|
595
|
+
|
|
596
|
+
const result = spawnTool('bin/formal-test-sync.cjs', [
|
|
597
|
+
'--json',
|
|
598
|
+
'--report-only',
|
|
599
|
+
]);
|
|
600
|
+
|
|
601
|
+
if (!result.ok) {
|
|
602
|
+
formalTestSyncCache = null;
|
|
603
|
+
return null;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
try {
|
|
607
|
+
formalTestSyncCache = JSON.parse(result.stdout);
|
|
608
|
+
return formalTestSyncCache;
|
|
609
|
+
} catch (err) {
|
|
610
|
+
formalTestSyncCache = null;
|
|
611
|
+
return null;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* F->T: Formal to Tests coverage.
|
|
617
|
+
* Returns { residual: N, detail: {...} }
|
|
618
|
+
*/
|
|
619
|
+
function sweepFtoT() {
|
|
620
|
+
const syncData = loadFormalTestSync();
|
|
621
|
+
|
|
622
|
+
if (!syncData) {
|
|
623
|
+
return {
|
|
624
|
+
residual: -1,
|
|
625
|
+
detail: { error: 'formal-test-sync.cjs failed' },
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const gaps = syncData.coverage_gaps || {};
|
|
630
|
+
const stats = gaps.stats || {};
|
|
631
|
+
const gapCount = stats.gap_count || 0;
|
|
632
|
+
const gapsList = gaps.gaps || [];
|
|
633
|
+
|
|
634
|
+
return {
|
|
635
|
+
residual: gapCount,
|
|
636
|
+
detail: {
|
|
637
|
+
gap_count: gapCount,
|
|
638
|
+
formal_covered: stats.formal_covered || 0,
|
|
639
|
+
test_covered: stats.test_covered || 0,
|
|
640
|
+
gaps: gapsList.map((g) => g.requirement_id || g),
|
|
641
|
+
},
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* C->F: Code constants to Formal constants.
|
|
647
|
+
* Returns { residual: N, detail: {...} }
|
|
648
|
+
*/
|
|
649
|
+
function sweepCtoF() {
|
|
650
|
+
const syncData = loadFormalTestSync();
|
|
651
|
+
|
|
652
|
+
if (!syncData) {
|
|
653
|
+
return {
|
|
654
|
+
residual: -1,
|
|
655
|
+
detail: { error: 'formal-test-sync.cjs failed' },
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const validation = syncData.constants_validation || [];
|
|
660
|
+
const mismatches = validation.filter((entry) => {
|
|
661
|
+
return (
|
|
662
|
+
entry.match === false &&
|
|
663
|
+
entry.intentional_divergence !== true &&
|
|
664
|
+
entry.config_path !== null
|
|
665
|
+
);
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
return {
|
|
669
|
+
residual: mismatches.length,
|
|
670
|
+
detail: {
|
|
671
|
+
mismatches: mismatches.map((m) => ({
|
|
672
|
+
constant: m.constant,
|
|
673
|
+
source: m.source,
|
|
674
|
+
formal_value: m.formal_value,
|
|
675
|
+
config_value: m.config_value,
|
|
676
|
+
})),
|
|
677
|
+
},
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* T->C: Tests to Code.
|
|
683
|
+
* Returns { residual: N, detail: {...} }
|
|
684
|
+
*/
|
|
685
|
+
function sweepTtoC() {
|
|
686
|
+
// Load configurable test runner settings
|
|
687
|
+
const configPath = path.join(ROOT, '.planning', 'config.json');
|
|
688
|
+
let tToCConfig = { runner: 'node-test', command: null, scope: 'all' };
|
|
689
|
+
try {
|
|
690
|
+
const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
691
|
+
if (cfg.solve && cfg.solve.t_to_c) {
|
|
692
|
+
tToCConfig = { ...tToCConfig, ...cfg.solve.t_to_c };
|
|
693
|
+
}
|
|
694
|
+
} catch (e) { /* use defaults */ }
|
|
695
|
+
|
|
696
|
+
// Runner mode: none — skip entirely
|
|
697
|
+
if (tToCConfig.runner === 'none') {
|
|
698
|
+
return { residual: 0, detail: { skipped: true, reason: 'runner=none in config' } };
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const spawnOpts = {
|
|
702
|
+
encoding: 'utf8',
|
|
703
|
+
cwd: ROOT,
|
|
704
|
+
timeout: 120000,
|
|
705
|
+
stdio: verboseMode ? ['pipe', 'pipe', 'inherit'] : 'pipe',
|
|
706
|
+
};
|
|
707
|
+
|
|
708
|
+
// Runner mode: jest
|
|
709
|
+
if (tToCConfig.runner === 'jest') {
|
|
710
|
+
const jestCmd = tToCConfig.command || 'npx';
|
|
711
|
+
const jestArgs = tToCConfig.command ? [] : ['jest', '--ci', '--json'];
|
|
712
|
+
let result;
|
|
713
|
+
try {
|
|
714
|
+
result = spawnSync(jestCmd, jestArgs, spawnOpts);
|
|
715
|
+
} catch (err) {
|
|
716
|
+
return {
|
|
717
|
+
residual: -1,
|
|
718
|
+
detail: { error: 'Failed to spawn jest: ' + err.message },
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const output = (result.stdout || '') + (result.stderr || '');
|
|
723
|
+
// Try to parse Jest JSON output
|
|
724
|
+
try {
|
|
725
|
+
const jsonStart = output.indexOf('{');
|
|
726
|
+
if (jsonStart >= 0) {
|
|
727
|
+
const jestResult = JSON.parse(output.slice(jsonStart));
|
|
728
|
+
const failCount = jestResult.numFailedTests || 0;
|
|
729
|
+
const totalTests = jestResult.numTotalTests || 0;
|
|
730
|
+
return {
|
|
731
|
+
residual: failCount,
|
|
732
|
+
detail: {
|
|
733
|
+
total_tests: totalTests,
|
|
734
|
+
passed: totalTests - failCount,
|
|
735
|
+
failed: failCount,
|
|
736
|
+
skipped: 0,
|
|
737
|
+
todo: 0,
|
|
738
|
+
runner: 'jest',
|
|
739
|
+
},
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
} catch (e) { /* fall through to TAP parsing */ }
|
|
743
|
+
|
|
744
|
+
return {
|
|
745
|
+
residual: -1,
|
|
746
|
+
detail: { error: 'Failed to parse jest output', runner: 'jest' },
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// Runner mode: node-test (default)
|
|
751
|
+
let result;
|
|
752
|
+
try {
|
|
753
|
+
result = spawnSync(process.execPath, ['--test'], spawnOpts);
|
|
754
|
+
} catch (err) {
|
|
755
|
+
return {
|
|
756
|
+
residual: -1,
|
|
757
|
+
detail: { error: 'Failed to spawn node --test: ' + err.message },
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const output = (result.stdout || '') + (result.stderr || '');
|
|
762
|
+
|
|
763
|
+
// Parse TAP output for test summary.
|
|
764
|
+
// Support both # prefix (Node <= v24) and ℹ prefix (Node v25+)
|
|
765
|
+
let totalTests = 0;
|
|
766
|
+
let failCount = 0;
|
|
767
|
+
let skipCount = 0;
|
|
768
|
+
let todoCount = 0;
|
|
769
|
+
|
|
770
|
+
const testsMatch = output.match(/^[ℹ#]\s+tests\s+(\d+)/m);
|
|
771
|
+
if (testsMatch) totalTests = parseInt(testsMatch[1], 10);
|
|
772
|
+
|
|
773
|
+
const failMatch = output.match(/^[ℹ#]\s+fail\s+(\d+)/m);
|
|
774
|
+
if (failMatch) failCount = parseInt(failMatch[1], 10);
|
|
775
|
+
|
|
776
|
+
const skipMatch = output.match(/^[ℹ#]\s+skipped\s+(\d+)/m);
|
|
777
|
+
if (skipMatch) skipCount = parseInt(skipMatch[1], 10);
|
|
778
|
+
|
|
779
|
+
const todoMatch = output.match(/^[ℹ#]\s+todo\s+(\d+)/m);
|
|
780
|
+
if (todoMatch) todoCount = parseInt(todoMatch[1], 10);
|
|
781
|
+
|
|
782
|
+
// Fallback: count "not ok" lines if summary not found
|
|
783
|
+
if (failCount === 0 && totalTests === 0) {
|
|
784
|
+
const notOkMatches = output.match(/^not ok\s+\d+/gm) || [];
|
|
785
|
+
failCount = notOkMatches.length;
|
|
786
|
+
const okMatches = output.match(/^ok\s+\d+/gm) || [];
|
|
787
|
+
totalTests = notOkMatches.length + okMatches.length;
|
|
788
|
+
skipCount = 0;
|
|
789
|
+
todoCount = 0;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// Scope-based auto-detection: if scope is "generated-stubs-only", check if all failures
|
|
793
|
+
// are outside .planning/formal/generated-stubs/
|
|
794
|
+
if (tToCConfig.runner === 'node-test' && tToCConfig.scope === 'generated-stubs-only' && failCount > 0) {
|
|
795
|
+
const failLines = output.match(/^not ok\s+\d+\s+.*/gm) || [];
|
|
796
|
+
const stubFailures = failLines.filter(l => l.includes('generated-stubs'));
|
|
797
|
+
if (stubFailures.length === 0 && failLines.length > 0) {
|
|
798
|
+
return {
|
|
799
|
+
residual: 0,
|
|
800
|
+
detail: {
|
|
801
|
+
total_tests: totalTests,
|
|
802
|
+
passed: Math.max(0, totalTests - failCount - skipCount - todoCount),
|
|
803
|
+
failed: failCount,
|
|
804
|
+
skipped: skipCount,
|
|
805
|
+
todo: todoCount,
|
|
806
|
+
runner_mismatch: true,
|
|
807
|
+
warning: 'All ' + failLines.length + ' failures are outside generated-stubs scope — likely runner mismatch',
|
|
808
|
+
},
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
return {
|
|
814
|
+
residual: failCount + skipCount,
|
|
815
|
+
detail: {
|
|
816
|
+
total_tests: totalTests,
|
|
817
|
+
passed: Math.max(0, totalTests - failCount - skipCount - todoCount),
|
|
818
|
+
failed: failCount,
|
|
819
|
+
skipped: skipCount,
|
|
820
|
+
todo: todoCount,
|
|
821
|
+
},
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* F->C: Formal verification to Code.
|
|
827
|
+
* Returns { residual: N, detail: {...} }
|
|
828
|
+
*/
|
|
829
|
+
function sweepFtoC() {
|
|
830
|
+
const verifyScript = path.join(SCRIPT_DIR, 'run-formal-verify.cjs');
|
|
831
|
+
|
|
832
|
+
if (!fs.existsSync(verifyScript)) {
|
|
833
|
+
return {
|
|
834
|
+
residual: 0,
|
|
835
|
+
detail: { skipped: true, reason: 'run-formal-verify.cjs not found' },
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// Always run formal verification to get fresh data (all 88+ checks).
|
|
840
|
+
// Previously, report-only mode read stale check-results.ndjson which often
|
|
841
|
+
// contained only 4 CI-gated checks, hiding individual alloy/tla/prism failures.
|
|
842
|
+
// Now matches sweepTtoC behavior: always compute fresh diagnostic data.
|
|
843
|
+
// The --report-only flag prevents auto-close remediation (line ~1400), not data collection.
|
|
844
|
+
//
|
|
845
|
+
// stdio: discard stdout ('ignore') because run-formal-verify.cjs writes ~4MB of
|
|
846
|
+
// verbose progress output. We only need the NDJSON file it writes to disk.
|
|
847
|
+
// Without this, spawnSync's maxBuffer limit kills the child mid-run, resulting
|
|
848
|
+
// in a partial NDJSON with only 3-4 CI checks instead of the full 88+.
|
|
849
|
+
const result = spawnTool('bin/run-formal-verify.cjs', [], {
|
|
850
|
+
timeout: 300000,
|
|
851
|
+
stdio: ['pipe', 'ignore', 'pipe'],
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
// Non-zero exit is expected when checks fail — still parse check-results.ndjson.
|
|
855
|
+
// Only bail on spawn errors (result.stderr without any ndjson output).
|
|
856
|
+
if (!result.ok && result.stderr && !fs.existsSync(path.join(ROOT, '.planning', 'formal', 'check-results.ndjson'))) {
|
|
857
|
+
return {
|
|
858
|
+
residual: -1,
|
|
859
|
+
detail: { error: result.stderr.slice(0, 500) || 'run-formal-verify.cjs failed' },
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// Parse .planning/formal/check-results.ndjson
|
|
864
|
+
const checkResultsPath = path.join(ROOT, '.planning', 'formal', 'check-results.ndjson');
|
|
865
|
+
|
|
866
|
+
if (!fs.existsSync(checkResultsPath)) {
|
|
867
|
+
return {
|
|
868
|
+
residual: 0,
|
|
869
|
+
detail: { note: 'No check-results.ndjson generated' },
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
try {
|
|
874
|
+
const lines = fs.readFileSync(checkResultsPath, 'utf8').split('\n');
|
|
875
|
+
let failedCount = 0;
|
|
876
|
+
let inconclusiveCount = 0;
|
|
877
|
+
let totalCount = 0;
|
|
878
|
+
const failures = [];
|
|
879
|
+
const inconclusiveChecks = [];
|
|
880
|
+
|
|
881
|
+
for (const line of lines) {
|
|
882
|
+
if (!line.trim()) continue;
|
|
883
|
+
totalCount++;
|
|
884
|
+
try {
|
|
885
|
+
const entry = JSON.parse(line);
|
|
886
|
+
if (entry.result === 'fail') {
|
|
887
|
+
failedCount++;
|
|
888
|
+
failures.push({
|
|
889
|
+
check_id: entry.check_id || entry.id || '?',
|
|
890
|
+
summary: entry.summary || '',
|
|
891
|
+
requirement_ids: entry.requirement_ids || [],
|
|
892
|
+
});
|
|
893
|
+
} else if (entry.result === 'inconclusive') {
|
|
894
|
+
inconclusiveCount++;
|
|
895
|
+
inconclusiveChecks.push({
|
|
896
|
+
check_id: entry.check_id || entry.id || '?',
|
|
897
|
+
summary: entry.summary || '',
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
} catch (e) {
|
|
901
|
+
// skip malformed lines
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
const existingDetail = {
|
|
906
|
+
total_checks: totalCount,
|
|
907
|
+
passed: Math.max(0, totalCount - failedCount - inconclusiveCount),
|
|
908
|
+
failed: failedCount,
|
|
909
|
+
inconclusive: inconclusiveCount,
|
|
910
|
+
failures: failures,
|
|
911
|
+
inconclusive_checks: inconclusiveChecks,
|
|
912
|
+
};
|
|
913
|
+
|
|
914
|
+
// Conformance trace self-healing: detect schema mismatch
|
|
915
|
+
const conformancePath = path.join(ROOT, '.planning', 'formal', 'trace', 'conformance-events.jsonl');
|
|
916
|
+
if (fs.existsSync(conformancePath)) {
|
|
917
|
+
try {
|
|
918
|
+
const events = fs.readFileSync(conformancePath, 'utf8').split('\n')
|
|
919
|
+
.filter(l => l.trim())
|
|
920
|
+
.map(l => { try { return JSON.parse(l); } catch(e) { return null; } })
|
|
921
|
+
.filter(Boolean);
|
|
922
|
+
|
|
923
|
+
const eventTypes = new Set(events.map(e => e.type || e.event));
|
|
924
|
+
|
|
925
|
+
// Try to load XState machine event types from spec/
|
|
926
|
+
const specDir = path.join(ROOT, '.planning', 'formal', 'spec');
|
|
927
|
+
let machineEventTypes = new Set();
|
|
928
|
+
if (fs.existsSync(specDir)) {
|
|
929
|
+
const specFiles = walkDir(specDir, 3, 0);
|
|
930
|
+
for (const f of specFiles) {
|
|
931
|
+
if (!f.endsWith('.json') && !f.endsWith('.js')) continue;
|
|
932
|
+
try {
|
|
933
|
+
const content = fs.readFileSync(f, 'utf8');
|
|
934
|
+
// Extract event types from "on": { "EVENT_NAME": ... } patterns
|
|
935
|
+
const onMatches = content.matchAll(/"on"\s*:\s*\{([^}]+)\}/g);
|
|
936
|
+
for (const m of onMatches) {
|
|
937
|
+
const keys = m[1].matchAll(/"([A-Z_]+)"/g);
|
|
938
|
+
for (const k of keys) machineEventTypes.add(k[1]);
|
|
939
|
+
}
|
|
940
|
+
} catch(e) { /* skip */ }
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
if (machineEventTypes.size > 0 && eventTypes.size > 0) {
|
|
945
|
+
const overlap = [...eventTypes].filter(t => machineEventTypes.has(t)).length;
|
|
946
|
+
const overlapPct = overlap / Math.max(eventTypes.size, 1);
|
|
947
|
+
|
|
948
|
+
if (overlapPct < 0.5) {
|
|
949
|
+
// Schema mismatch — reclassify
|
|
950
|
+
return {
|
|
951
|
+
residual: failedCount,
|
|
952
|
+
detail: {
|
|
953
|
+
...existingDetail,
|
|
954
|
+
schema_mismatch: true,
|
|
955
|
+
schema_mismatch_detail: {
|
|
956
|
+
trace_event_types: eventTypes.size,
|
|
957
|
+
machine_event_types: machineEventTypes.size,
|
|
958
|
+
overlap: overlap,
|
|
959
|
+
overlap_pct: (overlapPct * 100).toFixed(1) + '%',
|
|
960
|
+
},
|
|
961
|
+
note: 'Conformance trace has <50% event type overlap with state machine — likely schema mismatch, not verification failure',
|
|
962
|
+
},
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
} catch (e) {
|
|
967
|
+
// Conformance trace check failed — fail-open, continue with normal result
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
return {
|
|
972
|
+
residual: failedCount,
|
|
973
|
+
detail: existingDetail,
|
|
974
|
+
};
|
|
975
|
+
} catch (err) {
|
|
976
|
+
return {
|
|
977
|
+
residual: -1,
|
|
978
|
+
detail: { error: 'Failed to parse check-results.ndjson: ' + err.message },
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* R->D: Requirements to Documentation.
|
|
985
|
+
* Detects requirements not mentioned in developer docs (by ID or keyword match).
|
|
986
|
+
* Returns { residual: N, detail: {...} }
|
|
987
|
+
*/
|
|
988
|
+
function sweepRtoD() {
|
|
989
|
+
// Load requirements.json
|
|
990
|
+
const reqPath = path.join(ROOT, '.planning', 'formal', 'requirements.json');
|
|
991
|
+
if (!fs.existsSync(reqPath)) {
|
|
992
|
+
return {
|
|
993
|
+
residual: 0,
|
|
994
|
+
detail: { skipped: true, reason: 'requirements.json not found' },
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
let reqData;
|
|
999
|
+
try {
|
|
1000
|
+
reqData = JSON.parse(fs.readFileSync(reqPath, 'utf8'));
|
|
1001
|
+
} catch (e) {
|
|
1002
|
+
return {
|
|
1003
|
+
residual: 0,
|
|
1004
|
+
detail: { skipped: true, reason: 'requirements.json parse error: ' + e.message },
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// Discover doc files
|
|
1009
|
+
const allDiscovered = discoverDocFiles();
|
|
1010
|
+
// Only scan developer-category docs for R->D gap detection.
|
|
1011
|
+
// User docs (category='user') are human-controlled and must not drive auto-remediation.
|
|
1012
|
+
// Fall back to all docs only if no developer-category files exist (legacy setup).
|
|
1013
|
+
const developerDocs = allDiscovered.filter(f => f.category === 'developer');
|
|
1014
|
+
const docFiles = developerDocs.length > 0 ? developerDocs : allDiscovered;
|
|
1015
|
+
if (docFiles.length === 0) {
|
|
1016
|
+
return {
|
|
1017
|
+
residual: 0,
|
|
1018
|
+
detail: { skipped: true, reason: 'no doc files found' },
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// Concatenate all doc content
|
|
1023
|
+
let allDocContent = '';
|
|
1024
|
+
for (const { absPath } of docFiles) {
|
|
1025
|
+
try {
|
|
1026
|
+
allDocContent += fs.readFileSync(absPath, 'utf8') + '\n';
|
|
1027
|
+
} catch (e) {
|
|
1028
|
+
// skip unreadable files
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
const allDocContentLower = allDocContent.toLowerCase();
|
|
1032
|
+
|
|
1033
|
+
// Get requirements array - handle both flat array and envelope format
|
|
1034
|
+
let requirements = [];
|
|
1035
|
+
if (Array.isArray(reqData)) {
|
|
1036
|
+
requirements = reqData;
|
|
1037
|
+
} else if (reqData.requirements && Array.isArray(reqData.requirements)) {
|
|
1038
|
+
requirements = reqData.requirements;
|
|
1039
|
+
} else if (reqData.groups && Array.isArray(reqData.groups)) {
|
|
1040
|
+
// Envelope format with groups
|
|
1041
|
+
for (const group of reqData.groups) {
|
|
1042
|
+
if (group.requirements && Array.isArray(group.requirements)) {
|
|
1043
|
+
for (const r of group.requirements) requirements.push(r);
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
const undocumented = [];
|
|
1049
|
+
let documented = 0;
|
|
1050
|
+
|
|
1051
|
+
for (const req of requirements) {
|
|
1052
|
+
const id = req.id || req.requirement_id || '';
|
|
1053
|
+
const text = req.text || req.description || '';
|
|
1054
|
+
if (!id) continue;
|
|
1055
|
+
|
|
1056
|
+
// Primary: literal ID match (case-sensitive)
|
|
1057
|
+
if (allDocContent.includes(id)) {
|
|
1058
|
+
documented++;
|
|
1059
|
+
continue;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// Secondary: keyword match (case-insensitive, 3+ keywords)
|
|
1063
|
+
const keywords = extractKeywords(text);
|
|
1064
|
+
if (keywords.length > 0) {
|
|
1065
|
+
let matchCount = 0;
|
|
1066
|
+
for (const kw of keywords) {
|
|
1067
|
+
if (allDocContentLower.includes(kw)) {
|
|
1068
|
+
matchCount++;
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
if (matchCount >= 3) {
|
|
1072
|
+
documented++;
|
|
1073
|
+
continue;
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
undocumented.push(id);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
return {
|
|
1081
|
+
residual: undocumented.length,
|
|
1082
|
+
detail: {
|
|
1083
|
+
undocumented_requirements: undocumented,
|
|
1084
|
+
total_requirements: requirements.length,
|
|
1085
|
+
documented: documented,
|
|
1086
|
+
doc_files_scanned: docFiles.length,
|
|
1087
|
+
developer_docs_only: developerDocs.length > 0,
|
|
1088
|
+
},
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* D->C: Documentation to Code.
|
|
1094
|
+
* Detects stale structural claims in docs (dead file paths, missing CLI commands, absent dependencies).
|
|
1095
|
+
* Returns { residual: N, detail: {...} }
|
|
1096
|
+
*/
|
|
1097
|
+
function sweepDtoC() {
|
|
1098
|
+
const docFiles = discoverDocFiles();
|
|
1099
|
+
if (docFiles.length === 0) {
|
|
1100
|
+
return {
|
|
1101
|
+
residual: 0,
|
|
1102
|
+
detail: { skipped: true, reason: 'no doc files found' },
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// Load package.json for dependency verification
|
|
1107
|
+
let pkgDeps = {};
|
|
1108
|
+
let pkgDevDeps = {};
|
|
1109
|
+
try {
|
|
1110
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
|
|
1111
|
+
pkgDeps = pkg.dependencies || {};
|
|
1112
|
+
pkgDevDeps = pkg.devDependencies || {};
|
|
1113
|
+
} catch (e) {
|
|
1114
|
+
// No package.json — skip dependency checks
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
// Load acknowledged false positives
|
|
1118
|
+
const fpPath = path.join(ROOT, '.planning', 'formal', 'acknowledged-false-positives.json');
|
|
1119
|
+
let acknowledgedFPs = new Set();
|
|
1120
|
+
try {
|
|
1121
|
+
const fpData = JSON.parse(fs.readFileSync(fpPath, 'utf8'));
|
|
1122
|
+
for (const entry of (fpData.entries || [])) {
|
|
1123
|
+
// Key by doc_file + value only (no line numbers — line numbers shift on edits and break suppression)
|
|
1124
|
+
acknowledgedFPs.add(entry.doc_file + ':' + entry.value);
|
|
1125
|
+
}
|
|
1126
|
+
} catch (e) { /* no ack file */ }
|
|
1127
|
+
|
|
1128
|
+
// Load pattern-based suppression rules
|
|
1129
|
+
let fpPatterns = [];
|
|
1130
|
+
try {
|
|
1131
|
+
const fpData = JSON.parse(fs.readFileSync(fpPath, 'utf8'));
|
|
1132
|
+
for (const entry of (fpData.patterns || [])) {
|
|
1133
|
+
if (entry.enabled === false) continue;
|
|
1134
|
+
try {
|
|
1135
|
+
fpPatterns.push({ type: entry.type, regex: new RegExp(entry.regex), reason: entry.reason });
|
|
1136
|
+
} catch (regexErr) {
|
|
1137
|
+
console.warn('Skipping malformed FP pattern:', entry.regex, regexErr.message);
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
} catch (e) { /* no ack file or malformed */ }
|
|
1141
|
+
|
|
1142
|
+
// Severity weights: user-facing broken claims count more
|
|
1143
|
+
const CATEGORY_WEIGHT = { user: 2, examples: 1.5, developer: 1, unknown: 1 };
|
|
1144
|
+
|
|
1145
|
+
const brokenClaims = [];
|
|
1146
|
+
let totalClaimsChecked = 0;
|
|
1147
|
+
let suppressedFpCount = 0;
|
|
1148
|
+
|
|
1149
|
+
for (const { absPath, category } of docFiles) {
|
|
1150
|
+
let content;
|
|
1151
|
+
try {
|
|
1152
|
+
content = fs.readFileSync(absPath, 'utf8');
|
|
1153
|
+
} catch (e) {
|
|
1154
|
+
continue;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
const relativePath = path.relative(ROOT, absPath).replace(/\\/g, '/');
|
|
1158
|
+
const claims = extractStructuralClaims(content, relativePath);
|
|
1159
|
+
|
|
1160
|
+
for (const claim of claims) {
|
|
1161
|
+
totalClaimsChecked++;
|
|
1162
|
+
|
|
1163
|
+
let isBroken = false;
|
|
1164
|
+
let reason = '';
|
|
1165
|
+
|
|
1166
|
+
if (claim.type === 'file_path') {
|
|
1167
|
+
// Verify file exists
|
|
1168
|
+
const claimAbsPath = path.join(ROOT, claim.value);
|
|
1169
|
+
if (!fs.existsSync(claimAbsPath)) {
|
|
1170
|
+
isBroken = true;
|
|
1171
|
+
reason = 'file not found';
|
|
1172
|
+
}
|
|
1173
|
+
} else if (claim.type === 'cli_command') {
|
|
1174
|
+
// Extract script path from command (e.g., "node bin/foo.cjs" -> "bin/foo.cjs")
|
|
1175
|
+
const cmdParts = claim.value.split(/\s+/);
|
|
1176
|
+
if (cmdParts.length >= 2 && cmdParts[0] === 'node') {
|
|
1177
|
+
const scriptPath = cmdParts[1];
|
|
1178
|
+
if (!fs.existsSync(path.join(ROOT, scriptPath))) {
|
|
1179
|
+
isBroken = true;
|
|
1180
|
+
reason = 'script not found';
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
} else if (claim.type === 'dependency') {
|
|
1184
|
+
// Verify in package.json
|
|
1185
|
+
if (!(claim.value in pkgDeps) && !(claim.value in pkgDevDeps)) {
|
|
1186
|
+
isBroken = true;
|
|
1187
|
+
reason = 'not in package.json';
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
if (isBroken) {
|
|
1192
|
+
// Filter acknowledged false positives
|
|
1193
|
+
if (acknowledgedFPs.has(claim.doc_file + ':' + claim.value)) {
|
|
1194
|
+
suppressedFpCount++;
|
|
1195
|
+
continue;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// Filter by pattern-based suppression rules
|
|
1199
|
+
let patternSuppressed = false;
|
|
1200
|
+
for (const pat of fpPatterns) {
|
|
1201
|
+
if (pat.type === claim.type && pat.regex.test(claim.value)) {
|
|
1202
|
+
patternSuppressed = true;
|
|
1203
|
+
break;
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
if (patternSuppressed) {
|
|
1207
|
+
suppressedFpCount++;
|
|
1208
|
+
continue;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
// Reduce weight for historical/archived docs
|
|
1212
|
+
let effectiveCategory = category;
|
|
1213
|
+
const docLower = claim.doc_file.toLowerCase();
|
|
1214
|
+
if (docLower.includes('changelog') || docLower.includes('history') ||
|
|
1215
|
+
docLower.includes('archived/') || docLower.includes('deprecated/')) {
|
|
1216
|
+
effectiveCategory = '_historical';
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
brokenClaims.push({
|
|
1220
|
+
doc_file: claim.doc_file,
|
|
1221
|
+
line: claim.line,
|
|
1222
|
+
type: claim.type,
|
|
1223
|
+
value: claim.value,
|
|
1224
|
+
reason: reason,
|
|
1225
|
+
category: effectiveCategory === '_historical' ? category : category,
|
|
1226
|
+
weight: effectiveCategory === '_historical' ? 0.1 : (CATEGORY_WEIGHT[category] || 1),
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
// Weighted residual: user-facing broken claims count more
|
|
1233
|
+
let weightedResidual = 0;
|
|
1234
|
+
const categoryBreakdown = {};
|
|
1235
|
+
for (const bc of brokenClaims) {
|
|
1236
|
+
const w = bc.weight !== undefined ? bc.weight : (CATEGORY_WEIGHT[bc.category] || 1);
|
|
1237
|
+
weightedResidual += w;
|
|
1238
|
+
categoryBreakdown[bc.category] = (categoryBreakdown[bc.category] || 0) + 1;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
return {
|
|
1242
|
+
residual: Math.ceil(weightedResidual),
|
|
1243
|
+
detail: {
|
|
1244
|
+
broken_claims: brokenClaims,
|
|
1245
|
+
total_claims_checked: totalClaimsChecked,
|
|
1246
|
+
doc_files_scanned: docFiles.length,
|
|
1247
|
+
raw_broken_count: brokenClaims.length,
|
|
1248
|
+
weighted_residual: weightedResidual,
|
|
1249
|
+
category_breakdown: categoryBreakdown,
|
|
1250
|
+
suppressed_fp_count: suppressedFpCount,
|
|
1251
|
+
},
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
// ── Reverse traceability sweeps ──────────────────────────────────────────────
|
|
1256
|
+
|
|
1257
|
+
const MAX_REVERSE_CANDIDATES = 200;
|
|
1258
|
+
|
|
1259
|
+
/**
|
|
1260
|
+
* C->R: Code to Requirements (reverse).
|
|
1261
|
+
* Scans bin/ and hooks/ for source modules not traced to any requirement.
|
|
1262
|
+
* Returns { residual: N, detail: { untraced_modules: [{file}], total_modules, traced } }
|
|
1263
|
+
*/
|
|
1264
|
+
function sweepCtoR() {
|
|
1265
|
+
const reqPath = path.join(ROOT, '.planning', 'formal', 'requirements.json');
|
|
1266
|
+
if (!fs.existsSync(reqPath)) {
|
|
1267
|
+
return { residual: 0, detail: { skipped: true, reason: 'requirements.json not found' } };
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
let reqData;
|
|
1271
|
+
try {
|
|
1272
|
+
reqData = JSON.parse(fs.readFileSync(reqPath, 'utf8'));
|
|
1273
|
+
} catch (e) {
|
|
1274
|
+
return { residual: 0, detail: { skipped: true, reason: 'requirements.json parse error' } };
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
// Flatten requirements
|
|
1278
|
+
let requirements = [];
|
|
1279
|
+
if (Array.isArray(reqData)) {
|
|
1280
|
+
requirements = reqData;
|
|
1281
|
+
} else if (reqData.requirements && Array.isArray(reqData.requirements)) {
|
|
1282
|
+
requirements = reqData.requirements;
|
|
1283
|
+
} else if (reqData.groups && Array.isArray(reqData.groups)) {
|
|
1284
|
+
for (const group of reqData.groups) {
|
|
1285
|
+
if (group.requirements && Array.isArray(group.requirements)) {
|
|
1286
|
+
for (const r of group.requirements) requirements.push(r);
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
// Build searchable text from all requirements
|
|
1292
|
+
const allReqText = requirements.map(r => {
|
|
1293
|
+
const parts = [r.id || '', r.text || '', r.description || '', r.background || ''];
|
|
1294
|
+
if (r.provenance && r.provenance.source_file) parts.push(r.provenance.source_file);
|
|
1295
|
+
return parts.join(' ');
|
|
1296
|
+
}).join('\n');
|
|
1297
|
+
|
|
1298
|
+
// Scan bin/ and hooks/ for source modules
|
|
1299
|
+
const scanDirs = ['bin', 'hooks'];
|
|
1300
|
+
const sourceFiles = [];
|
|
1301
|
+
|
|
1302
|
+
for (const dir of scanDirs) {
|
|
1303
|
+
const absDir = path.join(ROOT, dir);
|
|
1304
|
+
if (!fs.existsSync(absDir)) continue;
|
|
1305
|
+
|
|
1306
|
+
let entries;
|
|
1307
|
+
try {
|
|
1308
|
+
entries = fs.readdirSync(absDir, { withFileTypes: true });
|
|
1309
|
+
} catch (e) {
|
|
1310
|
+
continue;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
for (const entry of entries) {
|
|
1314
|
+
if (!entry.isFile()) continue;
|
|
1315
|
+
if (!entry.name.endsWith('.cjs') && !entry.name.endsWith('.js') && !entry.name.endsWith('.mjs')) continue;
|
|
1316
|
+
// Skip test files, dist copies, and generated files
|
|
1317
|
+
if (entry.name.includes('.test.')) continue;
|
|
1318
|
+
if (dir === 'hooks' && entry.name === 'dist') continue;
|
|
1319
|
+
|
|
1320
|
+
sourceFiles.push(path.join(dir, entry.name));
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
// Also scan hooks/dist/ as separate entry point files
|
|
1325
|
+
const distDir = path.join(ROOT, 'hooks', 'dist');
|
|
1326
|
+
if (fs.existsSync(distDir)) {
|
|
1327
|
+
try {
|
|
1328
|
+
const distEntries = fs.readdirSync(distDir, { withFileTypes: true });
|
|
1329
|
+
for (const entry of distEntries) {
|
|
1330
|
+
if (!entry.isFile()) continue;
|
|
1331
|
+
if (!entry.name.endsWith('.cjs') && !entry.name.endsWith('.js')) continue;
|
|
1332
|
+
// dist/ files are copies — skip, they trace through their source in hooks/
|
|
1333
|
+
}
|
|
1334
|
+
} catch (e) {
|
|
1335
|
+
// skip
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
const untraced = [];
|
|
1340
|
+
let traced = 0;
|
|
1341
|
+
|
|
1342
|
+
for (const file of sourceFiles) {
|
|
1343
|
+
const fileName = path.basename(file);
|
|
1344
|
+
const fileNoExt = fileName.replace(/\.(cjs|js|mjs)$/, '');
|
|
1345
|
+
|
|
1346
|
+
// Check if any requirement references this file
|
|
1347
|
+
if (allReqText.includes(file) || allReqText.includes(fileName) || allReqText.includes(fileNoExt)) {
|
|
1348
|
+
traced++;
|
|
1349
|
+
} else {
|
|
1350
|
+
untraced.push({ file });
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
return {
|
|
1355
|
+
residual: untraced.length,
|
|
1356
|
+
detail: {
|
|
1357
|
+
untraced_modules: untraced,
|
|
1358
|
+
total_modules: sourceFiles.length,
|
|
1359
|
+
traced: traced,
|
|
1360
|
+
},
|
|
1361
|
+
};
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
/**
|
|
1365
|
+
* T->R: Tests to Requirements (reverse).
|
|
1366
|
+
* Scans test files for tests without @req annotation or formal-test-sync mapping.
|
|
1367
|
+
* Returns { residual: N, detail: { orphan_tests: [file_paths], total_tests, mapped } }
|
|
1368
|
+
*/
|
|
1369
|
+
function sweepTtoR() {
|
|
1370
|
+
// Discover test files
|
|
1371
|
+
const testPatterns = [
|
|
1372
|
+
{ dir: 'bin', suffix: '.test.cjs' },
|
|
1373
|
+
{ dir: 'test', suffix: '.test.cjs' },
|
|
1374
|
+
{ dir: 'test', suffix: '.test.js' },
|
|
1375
|
+
];
|
|
1376
|
+
|
|
1377
|
+
const testFiles = [];
|
|
1378
|
+
for (const { dir, suffix } of testPatterns) {
|
|
1379
|
+
const absDir = path.join(ROOT, dir);
|
|
1380
|
+
if (!fs.existsSync(absDir)) continue;
|
|
1381
|
+
|
|
1382
|
+
let entries;
|
|
1383
|
+
try {
|
|
1384
|
+
entries = fs.readdirSync(absDir, { withFileTypes: true });
|
|
1385
|
+
} catch (e) {
|
|
1386
|
+
continue;
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
for (const entry of entries) {
|
|
1390
|
+
if (!entry.isFile()) continue;
|
|
1391
|
+
if (!entry.name.endsWith(suffix)) continue;
|
|
1392
|
+
testFiles.push(path.join(dir, entry.name));
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
if (testFiles.length === 0) {
|
|
1397
|
+
return { residual: 0, detail: { orphan_tests: [], total_tests: 0, mapped: 0 } };
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
// Load formal-test-sync data for mapping info
|
|
1401
|
+
const syncData = loadFormalTestSync();
|
|
1402
|
+
const syncMappedFiles = new Set();
|
|
1403
|
+
if (syncData && syncData.coverage_gaps && syncData.coverage_gaps.gaps) {
|
|
1404
|
+
for (const gap of syncData.coverage_gaps.gaps) {
|
|
1405
|
+
if (gap.test_file) syncMappedFiles.add(gap.test_file);
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
// Also check stub files from generated-stubs directory
|
|
1409
|
+
if (syncData && syncData.generated_stubs) {
|
|
1410
|
+
for (const stub of syncData.generated_stubs) {
|
|
1411
|
+
if (stub.source_test) syncMappedFiles.add(stub.source_test);
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
const orphans = [];
|
|
1416
|
+
let mapped = 0;
|
|
1417
|
+
|
|
1418
|
+
for (const testFile of testFiles) {
|
|
1419
|
+
const absPath = path.join(ROOT, testFile);
|
|
1420
|
+
|
|
1421
|
+
// Check for @req annotation in file content
|
|
1422
|
+
let hasReqAnnotation = false;
|
|
1423
|
+
try {
|
|
1424
|
+
const content = fs.readFileSync(absPath, 'utf8');
|
|
1425
|
+
// Match patterns like: @req REQ-01, @req ACT-02, // req: STOP-03
|
|
1426
|
+
hasReqAnnotation = /@req\s+[A-Z]+-\d+/i.test(content) ||
|
|
1427
|
+
/\/\/\s*req:\s*[A-Z]+-\d+/i.test(content);
|
|
1428
|
+
} catch (e) {
|
|
1429
|
+
// Can't read — treat as orphan
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
// Check if formal-test-sync knows about this file
|
|
1433
|
+
const inSyncReport = syncMappedFiles.has(testFile) || syncMappedFiles.has(absPath);
|
|
1434
|
+
|
|
1435
|
+
if (hasReqAnnotation || inSyncReport) {
|
|
1436
|
+
mapped++;
|
|
1437
|
+
} else {
|
|
1438
|
+
orphans.push(testFile);
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
return {
|
|
1443
|
+
residual: orphans.length,
|
|
1444
|
+
detail: {
|
|
1445
|
+
orphan_tests: orphans,
|
|
1446
|
+
total_tests: testFiles.length,
|
|
1447
|
+
mapped: mapped,
|
|
1448
|
+
},
|
|
1449
|
+
};
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
/**
|
|
1453
|
+
* D->R: Docs to Requirements (reverse).
|
|
1454
|
+
* Extracts capability claims from docs and checks if requirements back them.
|
|
1455
|
+
* Returns { residual: N, detail: { unbacked_claims: [{doc_file, line, claim_text}], total_claims, backed } }
|
|
1456
|
+
*/
|
|
1457
|
+
function sweepDtoR() {
|
|
1458
|
+
const reqPath = path.join(ROOT, '.planning', 'formal', 'requirements.json');
|
|
1459
|
+
if (!fs.existsSync(reqPath)) {
|
|
1460
|
+
return { residual: 0, detail: { skipped: true, reason: 'requirements.json not found' } };
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
let reqData;
|
|
1464
|
+
try {
|
|
1465
|
+
reqData = JSON.parse(fs.readFileSync(reqPath, 'utf8'));
|
|
1466
|
+
} catch (e) {
|
|
1467
|
+
return { residual: 0, detail: { skipped: true, reason: 'requirements.json parse error' } };
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
// Flatten requirements and extract keywords per requirement
|
|
1471
|
+
let requirements = [];
|
|
1472
|
+
if (Array.isArray(reqData)) {
|
|
1473
|
+
requirements = reqData;
|
|
1474
|
+
} else if (reqData.requirements && Array.isArray(reqData.requirements)) {
|
|
1475
|
+
requirements = reqData.requirements;
|
|
1476
|
+
} else if (reqData.groups && Array.isArray(reqData.groups)) {
|
|
1477
|
+
for (const group of reqData.groups) {
|
|
1478
|
+
if (group.requirements && Array.isArray(group.requirements)) {
|
|
1479
|
+
for (const r of group.requirements) requirements.push(r);
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
const reqKeywordSets = requirements.map(r => {
|
|
1485
|
+
const text = (r.text || r.description || '') + ' ' + (r.background || '');
|
|
1486
|
+
return extractKeywords(text);
|
|
1487
|
+
});
|
|
1488
|
+
|
|
1489
|
+
// Discover doc files
|
|
1490
|
+
const docFiles = discoverDocFiles();
|
|
1491
|
+
if (docFiles.length === 0) {
|
|
1492
|
+
return { residual: 0, detail: { skipped: true, reason: 'no doc files found' } };
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
// Action verbs that indicate capability claims
|
|
1496
|
+
const ACTION_VERBS = [
|
|
1497
|
+
'supports', 'enables', 'provides', 'ensures', 'guarantees',
|
|
1498
|
+
'validates', 'enforces', 'detects', 'prevents', 'handles',
|
|
1499
|
+
'automates', 'generates', 'monitors', 'verifies', 'dispatches',
|
|
1500
|
+
];
|
|
1501
|
+
const verbPattern = new RegExp('\\b(' + ACTION_VERBS.join('|') + ')\\b', 'i');
|
|
1502
|
+
|
|
1503
|
+
const unbacked = [];
|
|
1504
|
+
let totalClaims = 0;
|
|
1505
|
+
let backed = 0;
|
|
1506
|
+
|
|
1507
|
+
for (const { absPath } of docFiles) {
|
|
1508
|
+
let content;
|
|
1509
|
+
try {
|
|
1510
|
+
content = fs.readFileSync(absPath, 'utf8');
|
|
1511
|
+
} catch (e) {
|
|
1512
|
+
continue;
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
const relativePath = path.relative(ROOT, absPath).replace(/\\/g, '/');
|
|
1516
|
+
const lines = content.split('\n');
|
|
1517
|
+
let inFencedBlock = false;
|
|
1518
|
+
|
|
1519
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1520
|
+
const line = lines[i];
|
|
1521
|
+
|
|
1522
|
+
// Skip fenced code blocks
|
|
1523
|
+
if (line.trimStart().startsWith('```')) {
|
|
1524
|
+
inFencedBlock = !inFencedBlock;
|
|
1525
|
+
continue;
|
|
1526
|
+
}
|
|
1527
|
+
if (inFencedBlock) continue;
|
|
1528
|
+
|
|
1529
|
+
// Skip headings, empty lines, and list markers only
|
|
1530
|
+
if (line.match(/^#{1,6}\s/) || line.trim().length === 0) continue;
|
|
1531
|
+
|
|
1532
|
+
// Check for action verb
|
|
1533
|
+
if (!verbPattern.test(line)) continue;
|
|
1534
|
+
|
|
1535
|
+
totalClaims++;
|
|
1536
|
+
|
|
1537
|
+
// Extract keywords from this claim line
|
|
1538
|
+
const claimKeywords = extractKeywords(line);
|
|
1539
|
+
if (claimKeywords.length < 2) {
|
|
1540
|
+
// Too few keywords to match meaningfully
|
|
1541
|
+
backed++;
|
|
1542
|
+
continue;
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
// Check if any requirement has 3+ keyword overlap
|
|
1546
|
+
let hasBacking = false;
|
|
1547
|
+
for (const reqKws of reqKeywordSets) {
|
|
1548
|
+
let overlap = 0;
|
|
1549
|
+
for (const kw of claimKeywords) {
|
|
1550
|
+
if (reqKws.includes(kw)) overlap++;
|
|
1551
|
+
}
|
|
1552
|
+
if (overlap >= 3) {
|
|
1553
|
+
hasBacking = true;
|
|
1554
|
+
break;
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
if (hasBacking) {
|
|
1559
|
+
backed++;
|
|
1560
|
+
} else {
|
|
1561
|
+
unbacked.push({
|
|
1562
|
+
doc_file: relativePath,
|
|
1563
|
+
line: i + 1,
|
|
1564
|
+
claim_text: line.trim().slice(0, 120),
|
|
1565
|
+
});
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
return {
|
|
1571
|
+
residual: unbacked.length,
|
|
1572
|
+
detail: {
|
|
1573
|
+
unbacked_claims: unbacked,
|
|
1574
|
+
total_claims: totalClaims,
|
|
1575
|
+
backed: backed,
|
|
1576
|
+
},
|
|
1577
|
+
};
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
/**
|
|
1581
|
+
* Classify a reverse discovery candidate into category A/B/C.
|
|
1582
|
+
* Category A (likely requirements): strong requirement language or source modules/tests.
|
|
1583
|
+
* Category B (likely documentation): descriptive/documentation language only.
|
|
1584
|
+
* Category C (ambiguous): needs human review.
|
|
1585
|
+
* @param {object} candidate - Candidate with file_or_claim, evidence, type fields
|
|
1586
|
+
* @returns {{ category: string, reason: string, suggestion: string }}
|
|
1587
|
+
*/
|
|
1588
|
+
function classifyCandidate(candidate) {
|
|
1589
|
+
const text = (candidate.file_or_claim || '').toLowerCase();
|
|
1590
|
+
|
|
1591
|
+
// Category A signals: strong requirement language
|
|
1592
|
+
// Use word-boundary regex to avoid false matches (e.g. "mustard" matching "must")
|
|
1593
|
+
// Consistent with triageRequirements() which also uses \b boundaries
|
|
1594
|
+
const reqSignals = ['must', 'shall', 'ensures', 'invariant', 'constraint', 'enforces', 'guarantees'];
|
|
1595
|
+
const hasReqLanguage = reqSignals.some(s => new RegExp('\\b' + s + '\\b', 'i').test(text));
|
|
1596
|
+
|
|
1597
|
+
// Category B signals: weak/descriptive language in doc claims
|
|
1598
|
+
const docSignals = ['supports', 'handles', 'provides', 'describes', 'documents', 'explains'];
|
|
1599
|
+
const hasDocLanguage = docSignals.some(s => new RegExp('\\b' + s + '\\b', 'i').test(text));
|
|
1600
|
+
|
|
1601
|
+
// Module and test types are more likely to be real requirements
|
|
1602
|
+
if (candidate.type === 'module' || candidate.type === 'test') {
|
|
1603
|
+
// Source modules and tests are usually genuine missing requirements
|
|
1604
|
+
return { category: 'A', reason: 'source ' + candidate.type + ' without requirement tracing', suggestion: 'approve' };
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
if (candidate.type === 'claim') {
|
|
1608
|
+
if (hasReqLanguage) {
|
|
1609
|
+
return { category: 'A', reason: 'strong requirement language in doc claim', suggestion: 'approve' };
|
|
1610
|
+
}
|
|
1611
|
+
if (hasDocLanguage && !hasReqLanguage) {
|
|
1612
|
+
return { category: 'B', reason: 'descriptive/documentation language only', suggestion: 'acknowledge' };
|
|
1613
|
+
}
|
|
1614
|
+
return { category: 'C', reason: 'ambiguous — review needed', suggestion: 'review' };
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
return { category: 'C', reason: 'unclassified candidate type', suggestion: 'review' };
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
/**
|
|
1621
|
+
* Assemble and deduplicate reverse traceability candidates from all 3 scanners.
|
|
1622
|
+
* Merges C→R, T→R, D→R results, deduplicates, filters, and respects acknowledged-not-required.json.
|
|
1623
|
+
* Returns { candidates: [...], total_raw, deduped, filtered, acknowledged }
|
|
1624
|
+
*/
|
|
1625
|
+
function assembleReverseCandidates(c_to_r, t_to_r, d_to_r) {
|
|
1626
|
+
const raw = [];
|
|
1627
|
+
|
|
1628
|
+
// Gather C→R candidates
|
|
1629
|
+
if (c_to_r.residual > 0 && c_to_r.detail.untraced_modules) {
|
|
1630
|
+
for (const mod of c_to_r.detail.untraced_modules) {
|
|
1631
|
+
raw.push({
|
|
1632
|
+
source_scanners: ['C→R'],
|
|
1633
|
+
evidence: mod.file,
|
|
1634
|
+
file_or_claim: mod.file,
|
|
1635
|
+
type: 'module',
|
|
1636
|
+
});
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
// Gather T→R candidates
|
|
1641
|
+
if (t_to_r.residual > 0 && t_to_r.detail.orphan_tests) {
|
|
1642
|
+
for (const testFile of t_to_r.detail.orphan_tests) {
|
|
1643
|
+
raw.push({
|
|
1644
|
+
source_scanners: ['T→R'],
|
|
1645
|
+
evidence: testFile,
|
|
1646
|
+
file_or_claim: testFile,
|
|
1647
|
+
type: 'test',
|
|
1648
|
+
});
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
// Gather D→R candidates
|
|
1653
|
+
if (d_to_r.residual > 0 && d_to_r.detail.unbacked_claims) {
|
|
1654
|
+
for (const claim of d_to_r.detail.unbacked_claims) {
|
|
1655
|
+
raw.push({
|
|
1656
|
+
source_scanners: ['D→R'],
|
|
1657
|
+
evidence: claim.doc_file + ':' + claim.line,
|
|
1658
|
+
file_or_claim: claim.claim_text,
|
|
1659
|
+
type: 'claim',
|
|
1660
|
+
});
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
const totalRaw = raw.length;
|
|
1665
|
+
|
|
1666
|
+
// Deduplicate: merge test files that correspond to source modules
|
|
1667
|
+
// e.g., test/foo.test.cjs and bin/foo.cjs → single candidate with both scanners
|
|
1668
|
+
const merged = [];
|
|
1669
|
+
const testToSource = new Map();
|
|
1670
|
+
|
|
1671
|
+
for (const candidate of raw) {
|
|
1672
|
+
if (candidate.type === 'test') {
|
|
1673
|
+
// Extract base name: test/foo.test.cjs → foo
|
|
1674
|
+
const baseName = path.basename(candidate.file_or_claim)
|
|
1675
|
+
.replace(/\.test\.(cjs|js|mjs)$/, '');
|
|
1676
|
+
testToSource.set(baseName, candidate);
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
const mergedTestBases = new Set();
|
|
1681
|
+
|
|
1682
|
+
for (const candidate of raw) {
|
|
1683
|
+
if (candidate.type === 'module') {
|
|
1684
|
+
const baseName = path.basename(candidate.file_or_claim)
|
|
1685
|
+
.replace(/\.(cjs|js|mjs)$/, '');
|
|
1686
|
+
const matchingTest = testToSource.get(baseName);
|
|
1687
|
+
if (matchingTest) {
|
|
1688
|
+
// Merge: combine scanners
|
|
1689
|
+
if (verboseMode) {
|
|
1690
|
+
process.stderr.write(TAG + ' Dedup: merged C→R ' + candidate.file_or_claim +
|
|
1691
|
+
' + T→R ' + matchingTest.file_or_claim + '\n');
|
|
1692
|
+
}
|
|
1693
|
+
merged.push({
|
|
1694
|
+
source_scanners: ['C→R', 'T→R'],
|
|
1695
|
+
evidence: candidate.file_or_claim + ' + ' + matchingTest.file_or_claim,
|
|
1696
|
+
file_or_claim: candidate.file_or_claim,
|
|
1697
|
+
type: 'module',
|
|
1698
|
+
});
|
|
1699
|
+
mergedTestBases.add(baseName);
|
|
1700
|
+
} else {
|
|
1701
|
+
merged.push(candidate);
|
|
1702
|
+
}
|
|
1703
|
+
} else if (candidate.type === 'test') {
|
|
1704
|
+
const baseName = path.basename(candidate.file_or_claim)
|
|
1705
|
+
.replace(/\.test\.(cjs|js|mjs)$/, '');
|
|
1706
|
+
if (!mergedTestBases.has(baseName)) {
|
|
1707
|
+
merged.push(candidate);
|
|
1708
|
+
}
|
|
1709
|
+
} else {
|
|
1710
|
+
merged.push(candidate);
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
const deduped = totalRaw - merged.length;
|
|
1715
|
+
|
|
1716
|
+
// Filter out .planning/ files, generated stubs, node_modules
|
|
1717
|
+
let filtered = 0;
|
|
1718
|
+
const candidates = [];
|
|
1719
|
+
for (const c of merged) {
|
|
1720
|
+
if (c.file_or_claim.startsWith('.planning/') ||
|
|
1721
|
+
c.file_or_claim.includes('generated-stubs') ||
|
|
1722
|
+
c.file_or_claim.includes('node_modules')) {
|
|
1723
|
+
filtered++;
|
|
1724
|
+
continue;
|
|
1725
|
+
}
|
|
1726
|
+
candidates.push(c);
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
// Load acknowledged-not-required.json and filter out previously rejected
|
|
1730
|
+
let acknowledged = 0;
|
|
1731
|
+
const ackPath = path.join(ROOT, '.planning', 'formal', 'acknowledged-not-required.json');
|
|
1732
|
+
if (fs.existsSync(ackPath)) {
|
|
1733
|
+
try {
|
|
1734
|
+
const ackData = JSON.parse(fs.readFileSync(ackPath, 'utf8'));
|
|
1735
|
+
const ackSet = new Set((ackData.entries || []).map(e => e.file_or_claim));
|
|
1736
|
+
const afterAck = [];
|
|
1737
|
+
for (const c of candidates) {
|
|
1738
|
+
if (ackSet.has(c.file_or_claim)) {
|
|
1739
|
+
acknowledged++;
|
|
1740
|
+
} else {
|
|
1741
|
+
afterAck.push(c);
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
candidates.length = 0;
|
|
1745
|
+
for (const c of afterAck) candidates.push(c);
|
|
1746
|
+
} catch (e) {
|
|
1747
|
+
// malformed ack file — fail-open
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
// Auto-categorize candidates into A/B/C
|
|
1752
|
+
for (const c of candidates) {
|
|
1753
|
+
const classification = classifyCandidate(c);
|
|
1754
|
+
c.category = classification.category;
|
|
1755
|
+
c.category_reason = classification.reason;
|
|
1756
|
+
c.suggestion = classification.suggestion;
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
// Auto-acknowledge Category B candidates (documentation-only, no human review needed)
|
|
1760
|
+
const catBCandidates = candidates.filter(c => c.category === 'B');
|
|
1761
|
+
const autoAcknowledgedB = catBCandidates.length;
|
|
1762
|
+
|
|
1763
|
+
if (catBCandidates.length > 0 && !reportOnly) {
|
|
1764
|
+
// Write Category B to acknowledged-not-required.json
|
|
1765
|
+
const ackNrPath = path.join(ROOT, '.planning', 'formal', 'acknowledged-not-required.json');
|
|
1766
|
+
let ackNrData = { entries: [] };
|
|
1767
|
+
try {
|
|
1768
|
+
ackNrData = JSON.parse(fs.readFileSync(ackNrPath, 'utf8'));
|
|
1769
|
+
if (!Array.isArray(ackNrData.entries)) ackNrData.entries = [];
|
|
1770
|
+
} catch (e) { /* create fresh */ }
|
|
1771
|
+
|
|
1772
|
+
const existingKeys = new Set(ackNrData.entries.map(e => e.file_or_claim));
|
|
1773
|
+
for (const c of catBCandidates) {
|
|
1774
|
+
if (!existingKeys.has(c.file_or_claim)) {
|
|
1775
|
+
ackNrData.entries.push({
|
|
1776
|
+
file_or_claim: c.file_or_claim,
|
|
1777
|
+
category: 'B',
|
|
1778
|
+
reason: c.category_reason,
|
|
1779
|
+
acknowledged_at: new Date().toISOString(),
|
|
1780
|
+
});
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
try {
|
|
1784
|
+
fs.writeFileSync(ackNrPath, JSON.stringify(ackNrData, null, 2) + '\n', 'utf8');
|
|
1785
|
+
} catch (e) {
|
|
1786
|
+
process.stderr.write(TAG + ' WARNING: could not write acknowledged-not-required.json: ' + e.message + '\n');
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
// Remove Category B from candidates (never surface to humans)
|
|
1791
|
+
const afterCatB = candidates.filter(c => c.category !== 'B');
|
|
1792
|
+
candidates.length = 0;
|
|
1793
|
+
for (const c of afterCatB) candidates.push(c);
|
|
1794
|
+
|
|
1795
|
+
// Count by category for summary
|
|
1796
|
+
const categoryCounts = { A: 0, B: 0, C: 0 };
|
|
1797
|
+
for (const c of candidates) {
|
|
1798
|
+
categoryCounts[c.category] = (categoryCounts[c.category] || 0) + 1;
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
// Apply max candidate cap (R3.6 improvement from copilot-1)
|
|
1802
|
+
if (candidates.length > MAX_REVERSE_CANDIDATES) {
|
|
1803
|
+
if (verboseMode) {
|
|
1804
|
+
process.stderr.write(TAG + ' Capping reverse candidates from ' +
|
|
1805
|
+
candidates.length + ' to ' + MAX_REVERSE_CANDIDATES + '\n');
|
|
1806
|
+
}
|
|
1807
|
+
candidates.length = MAX_REVERSE_CANDIDATES;
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
return {
|
|
1811
|
+
candidates: candidates,
|
|
1812
|
+
total_raw: totalRaw,
|
|
1813
|
+
deduped: deduped,
|
|
1814
|
+
filtered: filtered,
|
|
1815
|
+
acknowledged: acknowledged,
|
|
1816
|
+
auto_acknowledged_b: autoAcknowledgedB,
|
|
1817
|
+
category_counts: categoryCounts,
|
|
1818
|
+
};
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
// ── Residual computation ─────────────────────────────────────────────────────
|
|
1822
|
+
|
|
1823
|
+
/**
|
|
1824
|
+
* Computes residual vector for all layer transitions (8 forward + 3 reverse).
|
|
1825
|
+
* Returns residual object with forward layers + reverse discovery layers.
|
|
1826
|
+
*/
|
|
1827
|
+
function computeResidual() {
|
|
1828
|
+
const r_to_f = sweepRtoF();
|
|
1829
|
+
const f_to_t = sweepFtoT();
|
|
1830
|
+
const c_to_f = sweepCtoF();
|
|
1831
|
+
const t_to_c = fastMode
|
|
1832
|
+
? { residual: -1, detail: { skipped: true, reason: 'fast mode' } }
|
|
1833
|
+
: sweepTtoC();
|
|
1834
|
+
const f_to_c = fastMode
|
|
1835
|
+
? { residual: -1, detail: { skipped: true, reason: 'fast mode' } }
|
|
1836
|
+
: sweepFtoC();
|
|
1837
|
+
const r_to_d = sweepRtoD();
|
|
1838
|
+
const d_to_c = sweepDtoC();
|
|
1839
|
+
const p_to_f = sweepPtoF({ root: ROOT });
|
|
1840
|
+
|
|
1841
|
+
// Reverse traceability discovery (do NOT add to automatable total)
|
|
1842
|
+
const c_to_r = sweepCtoR();
|
|
1843
|
+
const t_to_r = sweepTtoR();
|
|
1844
|
+
const d_to_r = sweepDtoR();
|
|
1845
|
+
|
|
1846
|
+
const total =
|
|
1847
|
+
(r_to_f.residual >= 0 ? r_to_f.residual : 0) +
|
|
1848
|
+
(f_to_t.residual >= 0 ? f_to_t.residual : 0) +
|
|
1849
|
+
(c_to_f.residual >= 0 ? c_to_f.residual : 0) +
|
|
1850
|
+
(t_to_c.residual >= 0 ? t_to_c.residual : 0) +
|
|
1851
|
+
(f_to_c.residual >= 0 ? f_to_c.residual : 0) +
|
|
1852
|
+
(r_to_d.residual >= 0 ? r_to_d.residual : 0) +
|
|
1853
|
+
(d_to_c.residual >= 0 ? d_to_c.residual : 0) +
|
|
1854
|
+
(p_to_f.residual >= 0 ? p_to_f.residual : 0);
|
|
1855
|
+
|
|
1856
|
+
const reverse_discovery_total =
|
|
1857
|
+
(c_to_r.residual >= 0 ? c_to_r.residual : 0) +
|
|
1858
|
+
(t_to_r.residual >= 0 ? t_to_r.residual : 0) +
|
|
1859
|
+
(d_to_r.residual >= 0 ? d_to_r.residual : 0);
|
|
1860
|
+
|
|
1861
|
+
// Assemble deduplicated reverse candidates
|
|
1862
|
+
const assembled_candidates = assembleReverseCandidates(c_to_r, t_to_r, d_to_r);
|
|
1863
|
+
|
|
1864
|
+
return {
|
|
1865
|
+
r_to_f,
|
|
1866
|
+
f_to_t,
|
|
1867
|
+
c_to_f,
|
|
1868
|
+
t_to_c,
|
|
1869
|
+
f_to_c,
|
|
1870
|
+
r_to_d,
|
|
1871
|
+
d_to_c,
|
|
1872
|
+
p_to_f,
|
|
1873
|
+
c_to_r,
|
|
1874
|
+
t_to_r,
|
|
1875
|
+
d_to_r,
|
|
1876
|
+
assembled_candidates,
|
|
1877
|
+
total,
|
|
1878
|
+
reverse_discovery_total,
|
|
1879
|
+
timestamp: new Date().toISOString(),
|
|
1880
|
+
};
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
// ── Auto-close ───────────────────────────────────────────────────────────────
|
|
1884
|
+
|
|
1885
|
+
/**
|
|
1886
|
+
* Attempts to fix gaps found by the sweep.
|
|
1887
|
+
* Returns { actions_taken: [...], stubs_generated: N }
|
|
1888
|
+
*/
|
|
1889
|
+
function autoClose(residual) {
|
|
1890
|
+
const actions = [];
|
|
1891
|
+
|
|
1892
|
+
// F->T gaps: generate test stubs
|
|
1893
|
+
if (residual.f_to_t.residual > 0) {
|
|
1894
|
+
const result = spawnTool('bin/formal-test-sync.cjs', []);
|
|
1895
|
+
if (result.ok) {
|
|
1896
|
+
actions.push(
|
|
1897
|
+
'Generated test stubs for ' +
|
|
1898
|
+
residual.f_to_t.residual +
|
|
1899
|
+
' uncovered invariants'
|
|
1900
|
+
);
|
|
1901
|
+
} else {
|
|
1902
|
+
actions.push(
|
|
1903
|
+
'Could not auto-generate test stubs for ' +
|
|
1904
|
+
residual.f_to_t.residual +
|
|
1905
|
+
' invariants (formal-test-sync.cjs failed)'
|
|
1906
|
+
);
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
// C->F mismatches: log but do not auto-fix
|
|
1911
|
+
if (residual.c_to_f.residual > 0) {
|
|
1912
|
+
actions.push(
|
|
1913
|
+
'Cannot auto-fix ' +
|
|
1914
|
+
residual.c_to_f.residual +
|
|
1915
|
+
' constant mismatch(es) — manual review required'
|
|
1916
|
+
);
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
// T->C failures: log but do not auto-fix
|
|
1920
|
+
if (residual.t_to_c.residual > 0) {
|
|
1921
|
+
actions.push(
|
|
1922
|
+
residual.t_to_c.residual + ' test failure(s) — manual fix required'
|
|
1923
|
+
);
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
// R->F gaps: log with triage info
|
|
1927
|
+
if (residual.r_to_f.residual > 0) {
|
|
1928
|
+
const triageDetail = residual.r_to_f.detail.triage;
|
|
1929
|
+
if (triageDetail) {
|
|
1930
|
+
actions.push(
|
|
1931
|
+
triageDetail.high + ' HIGH + ' + triageDetail.medium +
|
|
1932
|
+
' MEDIUM priority requirements lack formal coverage'
|
|
1933
|
+
);
|
|
1934
|
+
} else {
|
|
1935
|
+
actions.push(
|
|
1936
|
+
residual.r_to_f.residual +
|
|
1937
|
+
' requirement(s) lack formal model coverage — manual modeling required'
|
|
1938
|
+
);
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
// F->C failures: log but do not auto-fix
|
|
1943
|
+
if (residual.f_to_c.residual > 0) {
|
|
1944
|
+
actions.push(
|
|
1945
|
+
residual.f_to_c.residual +
|
|
1946
|
+
' formal verification failure(s) — manual fix required'
|
|
1947
|
+
);
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
// R->D gaps: log but do not auto-fix (manual review)
|
|
1951
|
+
if (residual.r_to_d.residual > 0) {
|
|
1952
|
+
actions.push(
|
|
1953
|
+
residual.r_to_d.residual +
|
|
1954
|
+
' requirement(s) undocumented in developer docs — manual review required'
|
|
1955
|
+
);
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
// D->C stale claims: log but do not auto-fix (manual review)
|
|
1959
|
+
if (residual.d_to_c.residual > 0) {
|
|
1960
|
+
actions.push(
|
|
1961
|
+
residual.d_to_c.residual +
|
|
1962
|
+
' stale structural claim(s) in docs — manual review required'
|
|
1963
|
+
);
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
// P->F divergence: dispatch parameter updates or flag investigations
|
|
1967
|
+
if (residual.p_to_f && residual.p_to_f.residual > 0) {
|
|
1968
|
+
const result = autoClosePtoF(residual.p_to_f, {
|
|
1969
|
+
spawnTool: spawnTool,
|
|
1970
|
+
});
|
|
1971
|
+
for (const action of result.actions_taken) {
|
|
1972
|
+
actions.push(action);
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
return {
|
|
1977
|
+
actions_taken: actions,
|
|
1978
|
+
stubs_generated: residual.f_to_t.residual > 0 ? 1 : 0,
|
|
1979
|
+
};
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
// ── Health indicator ─────────────────────────────────────────────────────────
|
|
1983
|
+
|
|
1984
|
+
/**
|
|
1985
|
+
* Returns health string for a residual value.
|
|
1986
|
+
*/
|
|
1987
|
+
function healthIndicator(residual) {
|
|
1988
|
+
if (residual === -1) return '? UNKNOWN';
|
|
1989
|
+
if (residual === 0) return 'OK GREEN';
|
|
1990
|
+
if (residual >= 1 && residual <= 3) return '!! YELLOW';
|
|
1991
|
+
return 'XX RED';
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
// ── Report formatting ────────────────────────────────────────────────────────
|
|
1995
|
+
|
|
1996
|
+
/**
|
|
1997
|
+
* Formats human-readable report.
|
|
1998
|
+
*/
|
|
1999
|
+
function formatReport(iterations, finalResidual, converged) {
|
|
2000
|
+
const lines = [];
|
|
2001
|
+
|
|
2002
|
+
lines.push('[qgsd-solve] Consistency Solver Report');
|
|
2003
|
+
lines.push('');
|
|
2004
|
+
lines.push(
|
|
2005
|
+
'Iterations: ' +
|
|
2006
|
+
iterations.length +
|
|
2007
|
+
'/' +
|
|
2008
|
+
maxIterations +
|
|
2009
|
+
' (converged: ' +
|
|
2010
|
+
(converged ? 'yes' : 'no') +
|
|
2011
|
+
')'
|
|
2012
|
+
);
|
|
2013
|
+
lines.push('');
|
|
2014
|
+
|
|
2015
|
+
// Residual vector table
|
|
2016
|
+
lines.push('Layer Transition Residual Health');
|
|
2017
|
+
lines.push('─────────────────────────────────────────────');
|
|
2018
|
+
|
|
2019
|
+
const rows = [
|
|
2020
|
+
{
|
|
2021
|
+
label: 'R -> F (Req->Formal)',
|
|
2022
|
+
residual: finalResidual.r_to_f.residual,
|
|
2023
|
+
},
|
|
2024
|
+
{
|
|
2025
|
+
label: 'F -> T (Formal->Test)',
|
|
2026
|
+
residual: finalResidual.f_to_t.residual,
|
|
2027
|
+
},
|
|
2028
|
+
{
|
|
2029
|
+
label: 'C -> F (Code->Formal)',
|
|
2030
|
+
residual: finalResidual.c_to_f.residual,
|
|
2031
|
+
},
|
|
2032
|
+
{
|
|
2033
|
+
label: 'T -> C (Test->Code)',
|
|
2034
|
+
residual: finalResidual.t_to_c.residual,
|
|
2035
|
+
},
|
|
2036
|
+
{
|
|
2037
|
+
label: 'F -> C (Formal->Code)',
|
|
2038
|
+
residual: finalResidual.f_to_c.residual,
|
|
2039
|
+
},
|
|
2040
|
+
{
|
|
2041
|
+
label: 'R -> D (Req->Docs)',
|
|
2042
|
+
residual: finalResidual.r_to_d.residual,
|
|
2043
|
+
},
|
|
2044
|
+
{
|
|
2045
|
+
label: 'D -> C (Docs->Code)',
|
|
2046
|
+
residual: finalResidual.d_to_c.residual,
|
|
2047
|
+
},
|
|
2048
|
+
{
|
|
2049
|
+
label: 'P -> F (Prod->Formal)',
|
|
2050
|
+
residual: finalResidual.p_to_f ? finalResidual.p_to_f.residual : -1,
|
|
2051
|
+
},
|
|
2052
|
+
];
|
|
2053
|
+
|
|
2054
|
+
for (const row of rows) {
|
|
2055
|
+
const res =
|
|
2056
|
+
row.residual >= 0 ? row.residual : '?';
|
|
2057
|
+
const health = healthIndicator(row.residual);
|
|
2058
|
+
const line = row.label.padEnd(28) + String(res).padStart(4) + ' ' + health;
|
|
2059
|
+
lines.push(line);
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
lines.push('─────────────────────────────────────────────');
|
|
2063
|
+
lines.push('Total residual: ' + finalResidual.total);
|
|
2064
|
+
|
|
2065
|
+
// Reverse traceability discovery section
|
|
2066
|
+
if (finalResidual.c_to_r || finalResidual.t_to_r || finalResidual.d_to_r) {
|
|
2067
|
+
lines.push('');
|
|
2068
|
+
lines.push('Reverse Traceability Discovery (human-gated):');
|
|
2069
|
+
lines.push('─────────────────────────────────────────────');
|
|
2070
|
+
|
|
2071
|
+
const reverseRows = [
|
|
2072
|
+
{ label: 'C -> R (Code->Req)', residual: finalResidual.c_to_r ? finalResidual.c_to_r.residual : -1 },
|
|
2073
|
+
{ label: 'T -> R (Test->Req)', residual: finalResidual.t_to_r ? finalResidual.t_to_r.residual : -1 },
|
|
2074
|
+
{ label: 'D -> R (Docs->Req)', residual: finalResidual.d_to_r ? finalResidual.d_to_r.residual : -1 },
|
|
2075
|
+
];
|
|
2076
|
+
|
|
2077
|
+
for (const row of reverseRows) {
|
|
2078
|
+
const res = row.residual >= 0 ? row.residual : '?';
|
|
2079
|
+
const health = healthIndicator(row.residual);
|
|
2080
|
+
const line = row.label.padEnd(28) + String(res).padStart(4) + ' ' + health;
|
|
2081
|
+
lines.push(line);
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
const rdTotal = finalResidual.reverse_discovery_total || 0;
|
|
2085
|
+
lines.push('─────────────────────────────────────────────');
|
|
2086
|
+
lines.push('Discovery total: ' + rdTotal);
|
|
2087
|
+
|
|
2088
|
+
if (finalResidual.assembled_candidates && finalResidual.assembled_candidates.candidates.length > 0) {
|
|
2089
|
+
const ac = finalResidual.assembled_candidates;
|
|
2090
|
+
lines.push('Candidates: ' + ac.candidates.length + ' (raw: ' + ac.total_raw +
|
|
2091
|
+
', deduped: ' + ac.deduped + ', filtered: ' + ac.filtered +
|
|
2092
|
+
', acknowledged: ' + ac.acknowledged + ')');
|
|
2093
|
+
if (ac.category_counts) {
|
|
2094
|
+
lines.push(' Category A (likely reqs): ' + (ac.category_counts.A || 0) +
|
|
2095
|
+
', Category B (likely docs): ' + (ac.category_counts.B || 0) +
|
|
2096
|
+
', Category C (ambiguous): ' + (ac.category_counts.C || 0));
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
lines.push('');
|
|
2101
|
+
|
|
2102
|
+
// Per-layer detail sections (only non-zero)
|
|
2103
|
+
if (finalResidual.r_to_f.residual > 0) {
|
|
2104
|
+
lines.push('## R -> F (Requirements -> Formal)');
|
|
2105
|
+
const detail = finalResidual.r_to_f.detail;
|
|
2106
|
+
if (detail.uncovered_requirements && detail.uncovered_requirements.length > 0) {
|
|
2107
|
+
lines.push('Uncovered requirements:');
|
|
2108
|
+
for (const req of detail.uncovered_requirements) {
|
|
2109
|
+
lines.push(' - ' + req);
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
lines.push('');
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
if (finalResidual.f_to_t.residual > 0) {
|
|
2116
|
+
lines.push('## F -> T (Formal -> Tests)');
|
|
2117
|
+
const detail = finalResidual.f_to_t.detail;
|
|
2118
|
+
lines.push('Gap count: ' + detail.gap_count);
|
|
2119
|
+
if (detail.gaps && detail.gaps.length > 0) {
|
|
2120
|
+
lines.push('Requirements with gaps:');
|
|
2121
|
+
for (const gap of detail.gaps.slice(0, 10)) {
|
|
2122
|
+
lines.push(' - ' + gap);
|
|
2123
|
+
}
|
|
2124
|
+
if (detail.gaps.length > 10) {
|
|
2125
|
+
lines.push(' ... and ' + (detail.gaps.length - 10) + ' more');
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
lines.push('');
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
if (finalResidual.c_to_f.residual > 0) {
|
|
2132
|
+
lines.push('## C -> F (Code Constants -> Formal)');
|
|
2133
|
+
const detail = finalResidual.c_to_f.detail;
|
|
2134
|
+
if (detail.mismatches && detail.mismatches.length > 0) {
|
|
2135
|
+
lines.push('Mismatches:');
|
|
2136
|
+
for (const m of detail.mismatches.slice(0, 5)) {
|
|
2137
|
+
lines.push(
|
|
2138
|
+
' - ' +
|
|
2139
|
+
m.constant +
|
|
2140
|
+
': formal=' +
|
|
2141
|
+
m.formal_value +
|
|
2142
|
+
', config=' +
|
|
2143
|
+
m.config_value
|
|
2144
|
+
);
|
|
2145
|
+
}
|
|
2146
|
+
if (detail.mismatches.length > 5) {
|
|
2147
|
+
lines.push(' ... and ' + (detail.mismatches.length - 5) + ' more');
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
lines.push('');
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
if (finalResidual.t_to_c.residual > 0) {
|
|
2154
|
+
lines.push('## T -> C (Tests -> Code)');
|
|
2155
|
+
const detail = finalResidual.t_to_c.detail;
|
|
2156
|
+
const parts = [];
|
|
2157
|
+
if (detail.failed > 0) parts.push('\u2717 ' + detail.failed + ' failed');
|
|
2158
|
+
if (detail.skipped > 0) parts.push('\u2298 ' + detail.skipped + ' skipped');
|
|
2159
|
+
if (detail.todo > 0) parts.push('\u25F7 ' + detail.todo + ' todo');
|
|
2160
|
+
lines.push('Tests: ' + parts.join(', ') + ' (of ' + detail.total_tests + ' total)');
|
|
2161
|
+
lines.push('');
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
if (finalResidual.f_to_c.residual > 0 || (finalResidual.f_to_c.detail && finalResidual.f_to_c.detail.inconclusive > 0)) {
|
|
2165
|
+
lines.push('## F -> C (Formal -> Code)');
|
|
2166
|
+
const detail = finalResidual.f_to_c.detail;
|
|
2167
|
+
const parts = [];
|
|
2168
|
+
if (detail.passed > 0) parts.push(detail.passed + ' pass');
|
|
2169
|
+
if (detail.failed > 0) parts.push(detail.failed + ' fail');
|
|
2170
|
+
if (detail.inconclusive > 0) parts.push(detail.inconclusive + ' inconclusive');
|
|
2171
|
+
lines.push('Checks: ' + parts.join(', ') + ' (of ' + detail.total_checks + ' total)');
|
|
2172
|
+
if (detail.failures && detail.failures.length > 0) {
|
|
2173
|
+
lines.push('');
|
|
2174
|
+
lines.push('Failures:');
|
|
2175
|
+
for (const fail of detail.failures) {
|
|
2176
|
+
const f = typeof fail === 'string' ? { check_id: fail, summary: '' } : fail;
|
|
2177
|
+
lines.push(' ✗ ' + f.check_id + (f.summary ? ' — ' + f.summary : ''));
|
|
2178
|
+
if (f.requirement_ids && f.requirement_ids.length > 0) {
|
|
2179
|
+
lines.push(' reqs: ' + f.requirement_ids.join(', '));
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
2183
|
+
if (detail.inconclusive_checks && detail.inconclusive_checks.length > 0) {
|
|
2184
|
+
lines.push('');
|
|
2185
|
+
lines.push('Inconclusive:');
|
|
2186
|
+
for (const w of detail.inconclusive_checks) {
|
|
2187
|
+
lines.push(' ⚠ ' + w.check_id + (w.summary ? ' — ' + w.summary : ''));
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
if (detail.stale) {
|
|
2191
|
+
lines.push('');
|
|
2192
|
+
lines.push('Note: results may be stale (from cached check-results.ndjson)');
|
|
2193
|
+
}
|
|
2194
|
+
lines.push('');
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
if (finalResidual.r_to_d.residual > 0) {
|
|
2198
|
+
lines.push('## R -> D (Requirements -> Docs)');
|
|
2199
|
+
const detail = finalResidual.r_to_d.detail;
|
|
2200
|
+
if (detail.undocumented_requirements && detail.undocumented_requirements.length > 0) {
|
|
2201
|
+
lines.push('Undocumented requirements:');
|
|
2202
|
+
for (const req of detail.undocumented_requirements.slice(0, 20)) {
|
|
2203
|
+
lines.push(' - ' + req);
|
|
2204
|
+
}
|
|
2205
|
+
if (detail.undocumented_requirements.length > 20) {
|
|
2206
|
+
lines.push(' ... and ' + (detail.undocumented_requirements.length - 20) + ' more');
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
lines.push('');
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
if (finalResidual.d_to_c.residual > 0) {
|
|
2213
|
+
lines.push('## D -> C (Docs -> Code)');
|
|
2214
|
+
const detail = finalResidual.d_to_c.detail;
|
|
2215
|
+
if (detail.broken_claims && detail.broken_claims.length > 0) {
|
|
2216
|
+
lines.push('Broken structural claims:');
|
|
2217
|
+
for (const claim of detail.broken_claims.slice(0, 20)) {
|
|
2218
|
+
lines.push(' - ' + claim.doc_file + ':' + claim.line + ' [' + claim.type + '] `' + claim.value + '` — ' + claim.reason);
|
|
2219
|
+
}
|
|
2220
|
+
if (detail.broken_claims.length > 20) {
|
|
2221
|
+
lines.push(' ... and ' + (detail.broken_claims.length - 20) + ' more');
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
lines.push('');
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
if (finalResidual.p_to_f && finalResidual.p_to_f.residual > 0) {
|
|
2228
|
+
lines.push('## P -> F (Production -> Formal)');
|
|
2229
|
+
const detail = finalResidual.p_to_f.detail;
|
|
2230
|
+
if (detail.divergent_entries && detail.divergent_entries.length > 0) {
|
|
2231
|
+
lines.push('Divergent entries:');
|
|
2232
|
+
for (const ent of detail.divergent_entries.slice(0, 20)) {
|
|
2233
|
+
lines.push(' - ' + ent.id + ': ' + ent.formal_ref + ' (measured: ' + ent.measured + ', expected: ' + ent.expected + ')');
|
|
2234
|
+
}
|
|
2235
|
+
if (detail.divergent_entries.length > 20) {
|
|
2236
|
+
lines.push(' ... and ' + (detail.divergent_entries.length - 20) + ' more');
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
if (detail.skipped_unlinked > 0) {
|
|
2240
|
+
lines.push('Skipped (waiting for formal link): ' + detail.skipped_unlinked);
|
|
2241
|
+
}
|
|
2242
|
+
lines.push('');
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
// Reverse traceability detail
|
|
2246
|
+
if (finalResidual.c_to_r && finalResidual.c_to_r.residual > 0) {
|
|
2247
|
+
lines.push('## C -> R (Code -> Requirements) [reverse discovery]');
|
|
2248
|
+
const detail = finalResidual.c_to_r.detail;
|
|
2249
|
+
if (detail.untraced_modules && detail.untraced_modules.length > 0) {
|
|
2250
|
+
lines.push('Untraced modules (' + detail.untraced_modules.length + ' of ' + detail.total_modules + '):');
|
|
2251
|
+
for (const mod of detail.untraced_modules.slice(0, 20)) {
|
|
2252
|
+
lines.push(' - ' + mod.file);
|
|
2253
|
+
}
|
|
2254
|
+
if (detail.untraced_modules.length > 20) {
|
|
2255
|
+
lines.push(' ... and ' + (detail.untraced_modules.length - 20) + ' more');
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
lines.push('');
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
if (finalResidual.t_to_r && finalResidual.t_to_r.residual > 0) {
|
|
2262
|
+
lines.push('## T -> R (Tests -> Requirements) [reverse discovery]');
|
|
2263
|
+
const detail = finalResidual.t_to_r.detail;
|
|
2264
|
+
if (detail.orphan_tests && detail.orphan_tests.length > 0) {
|
|
2265
|
+
lines.push('Orphan tests (' + detail.orphan_tests.length + ' of ' + detail.total_tests + '):');
|
|
2266
|
+
for (const t of detail.orphan_tests.slice(0, 20)) {
|
|
2267
|
+
lines.push(' - ' + t);
|
|
2268
|
+
}
|
|
2269
|
+
if (detail.orphan_tests.length > 20) {
|
|
2270
|
+
lines.push(' ... and ' + (detail.orphan_tests.length - 20) + ' more');
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
lines.push('');
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
if (finalResidual.d_to_r && finalResidual.d_to_r.residual > 0) {
|
|
2277
|
+
lines.push('## D -> R (Docs -> Requirements) [reverse discovery]');
|
|
2278
|
+
const detail = finalResidual.d_to_r.detail;
|
|
2279
|
+
if (detail.unbacked_claims && detail.unbacked_claims.length > 0) {
|
|
2280
|
+
lines.push('Unbacked doc claims (' + detail.unbacked_claims.length + ' of ' + detail.total_claims + '):');
|
|
2281
|
+
for (const c of detail.unbacked_claims.slice(0, 20)) {
|
|
2282
|
+
lines.push(' - ' + c.doc_file + ':' + c.line + ' — ' + c.claim_text);
|
|
2283
|
+
}
|
|
2284
|
+
if (detail.unbacked_claims.length > 20) {
|
|
2285
|
+
lines.push(' ... and ' + (detail.unbacked_claims.length - 20) + ' more');
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
2288
|
+
lines.push('');
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
return lines.join('\n');
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
/**
|
|
2295
|
+
* Truncate detail arrays in a residual object to keep JSON output within pipe buffer limits.
|
|
2296
|
+
* Returns a shallow copy with truncated arrays and a `truncated` flag if applicable.
|
|
2297
|
+
*/
|
|
2298
|
+
function truncateResidualDetail(residual) {
|
|
2299
|
+
const MAX_DETAIL_ITEMS = 30;
|
|
2300
|
+
const copy = {};
|
|
2301
|
+
for (const key of Object.keys(residual)) {
|
|
2302
|
+
const val = residual[key];
|
|
2303
|
+
if (val && typeof val === 'object' && val.detail && typeof val.detail === 'object') {
|
|
2304
|
+
const detailCopy = Object.assign({}, val.detail);
|
|
2305
|
+
// Truncate large arrays in detail
|
|
2306
|
+
for (const dk of Object.keys(detailCopy)) {
|
|
2307
|
+
if (Array.isArray(detailCopy[dk]) && detailCopy[dk].length > MAX_DETAIL_ITEMS) {
|
|
2308
|
+
const totalCount = detailCopy[dk].length;
|
|
2309
|
+
detailCopy[dk] = detailCopy[dk].slice(0, MAX_DETAIL_ITEMS);
|
|
2310
|
+
detailCopy[dk + '_truncated'] = true;
|
|
2311
|
+
detailCopy[dk + '_total'] = totalCount;
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
copy[key] = { residual: val.residual, detail: detailCopy };
|
|
2315
|
+
} else {
|
|
2316
|
+
copy[key] = val;
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
return copy;
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
/**
|
|
2323
|
+
* Formats JSON output.
|
|
2324
|
+
*/
|
|
2325
|
+
function formatJSON(iterations, finalResidual, converged) {
|
|
2326
|
+
const health = {};
|
|
2327
|
+
for (const key of ['r_to_f', 'f_to_t', 'c_to_f', 't_to_c', 'f_to_c', 'r_to_d', 'd_to_c', 'p_to_f', 'c_to_r', 't_to_r', 'd_to_r']) {
|
|
2328
|
+
const res = finalResidual[key] ? finalResidual[key].residual : -1;
|
|
2329
|
+
health[key] = healthIndicator(res).split(/\s+/)[1]; // Extract GREEN/YELLOW/RED/UNKNOWN
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
const truncatedResidual = truncateResidualDetail(finalResidual);
|
|
2333
|
+
|
|
2334
|
+
return {
|
|
2335
|
+
solver_version: '1.2',
|
|
2336
|
+
generated_at: new Date().toISOString(),
|
|
2337
|
+
fast_mode: fastMode ? true : false,
|
|
2338
|
+
iteration_count: iterations.length,
|
|
2339
|
+
max_iterations: maxIterations,
|
|
2340
|
+
converged: converged,
|
|
2341
|
+
residual_vector: truncatedResidual,
|
|
2342
|
+
iterations: iterations.map((it) => ({
|
|
2343
|
+
iteration: it.iteration,
|
|
2344
|
+
residual: truncateResidualDetail(it.residual),
|
|
2345
|
+
actions: it.actions || [],
|
|
2346
|
+
})),
|
|
2347
|
+
health: health,
|
|
2348
|
+
};
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
// ── Main ─────────────────────────────────────────────────────────────────────
|
|
2352
|
+
|
|
2353
|
+
function main() {
|
|
2354
|
+
// Step 0: Bootstrap formal infrastructure
|
|
2355
|
+
preflight();
|
|
2356
|
+
|
|
2357
|
+
const iterations = [];
|
|
2358
|
+
let converged = false;
|
|
2359
|
+
let prevTotal = null;
|
|
2360
|
+
|
|
2361
|
+
for (let i = 1; i <= maxIterations; i++) {
|
|
2362
|
+
process.stderr.write(TAG + ' Iteration ' + i + '/' + maxIterations + '\n');
|
|
2363
|
+
|
|
2364
|
+
// Clear formal-test-sync cache so computeResidual() sees fresh data after autoClose() mutations
|
|
2365
|
+
formalTestSyncCache = null;
|
|
2366
|
+
|
|
2367
|
+
const residual = computeResidual();
|
|
2368
|
+
const actions = [];
|
|
2369
|
+
iterations.push({ iteration: i, residual: residual, actions: actions });
|
|
2370
|
+
|
|
2371
|
+
// Check convergence: total residual unchanged from previous iteration
|
|
2372
|
+
if (prevTotal !== null && residual.total === prevTotal) {
|
|
2373
|
+
converged = true;
|
|
2374
|
+
process.stderr.write(
|
|
2375
|
+
TAG +
|
|
2376
|
+
' Converged at iteration ' +
|
|
2377
|
+
i +
|
|
2378
|
+
' (residual stable at ' +
|
|
2379
|
+
residual.total +
|
|
2380
|
+
')\n'
|
|
2381
|
+
);
|
|
2382
|
+
break;
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
// Check if already at zero
|
|
2386
|
+
if (residual.total === 0) {
|
|
2387
|
+
converged = true;
|
|
2388
|
+
process.stderr.write(TAG + ' All layers clean — residual is 0\n');
|
|
2389
|
+
break;
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
// Auto-close if not report-only and not last iteration
|
|
2393
|
+
if (!reportOnly) {
|
|
2394
|
+
const closeResult = autoClose(residual);
|
|
2395
|
+
iterations[iterations.length - 1].actions = closeResult.actions_taken;
|
|
2396
|
+
} else {
|
|
2397
|
+
break; // report-only = single sweep, no loop
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
prevTotal = residual.total;
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2403
|
+
const finalResidual = iterations[iterations.length - 1].residual;
|
|
2404
|
+
|
|
2405
|
+
// Write solver state persistence
|
|
2406
|
+
const solveState = {
|
|
2407
|
+
last_run: new Date().toISOString(),
|
|
2408
|
+
converged: converged,
|
|
2409
|
+
iteration_count: iterations.length,
|
|
2410
|
+
final_residual_total: finalResidual.total,
|
|
2411
|
+
reverse_discovery_total: finalResidual.reverse_discovery_total || 0,
|
|
2412
|
+
known_issues: [],
|
|
2413
|
+
r_to_f_progress: {
|
|
2414
|
+
total: finalResidual.r_to_f.detail.total || 0,
|
|
2415
|
+
covered: finalResidual.r_to_f.detail.covered || 0,
|
|
2416
|
+
percentage: finalResidual.r_to_f.detail.percentage || 0,
|
|
2417
|
+
},
|
|
2418
|
+
};
|
|
2419
|
+
// Collect known issues from non-zero non-error layers
|
|
2420
|
+
for (const [key, val] of Object.entries(finalResidual)) {
|
|
2421
|
+
if (val && typeof val === 'object' && val.residual > 0) {
|
|
2422
|
+
solveState.known_issues.push({ layer: key, residual: val.residual });
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
try {
|
|
2426
|
+
const stateDir = path.join(ROOT, '.planning', 'formal');
|
|
2427
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
2428
|
+
fs.writeFileSync(
|
|
2429
|
+
path.join(stateDir, 'solve-state.json'),
|
|
2430
|
+
JSON.stringify(solveState, null, 2) + '\n'
|
|
2431
|
+
);
|
|
2432
|
+
} catch (e) {
|
|
2433
|
+
process.stderr.write(TAG + ' WARNING: could not write solve-state.json: ' + e.message + '\n');
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
if (jsonMode) {
|
|
2437
|
+
process.stdout.write(
|
|
2438
|
+
JSON.stringify(formatJSON(iterations, finalResidual, converged), null, 2) +
|
|
2439
|
+
'\n'
|
|
2440
|
+
);
|
|
2441
|
+
} else {
|
|
2442
|
+
process.stdout.write(formatReport(iterations, finalResidual, converged));
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
// Exit with non-zero if residual > 0 (signals gaps remain)
|
|
2446
|
+
process.exit(finalResidual.total > 0 ? 1 : 0);
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
// ── Exports (for testing) ────────────────────────────────────────────────────
|
|
2450
|
+
|
|
2451
|
+
module.exports = {
|
|
2452
|
+
sweep: computeResidual,
|
|
2453
|
+
computeResidual,
|
|
2454
|
+
autoClose,
|
|
2455
|
+
formatReport,
|
|
2456
|
+
formatJSON,
|
|
2457
|
+
healthIndicator,
|
|
2458
|
+
preflight,
|
|
2459
|
+
triageRequirements,
|
|
2460
|
+
discoverDocFiles,
|
|
2461
|
+
extractKeywords,
|
|
2462
|
+
extractStructuralClaims,
|
|
2463
|
+
sweepRtoD,
|
|
2464
|
+
sweepDtoC,
|
|
2465
|
+
sweepTtoC,
|
|
2466
|
+
sweepCtoR,
|
|
2467
|
+
sweepTtoR,
|
|
2468
|
+
sweepDtoR,
|
|
2469
|
+
assembleReverseCandidates,
|
|
2470
|
+
classifyCandidate,
|
|
2471
|
+
};
|
|
2472
|
+
|
|
2473
|
+
// ── Entry point ──────────────────────────────────────────────────────────────
|
|
2474
|
+
|
|
2475
|
+
if (require.main === module) {
|
|
2476
|
+
main();
|
|
2477
|
+
}
|