@nforma.ai/nforma 0.2.1 → 0.29.0
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/README.md +2 -2
- package/agents/{qgsd-codebase-mapper.md → nf-codebase-mapper.md} +1 -1
- package/agents/{qgsd-debugger.md → nf-debugger.md} +3 -3
- package/agents/{qgsd-executor.md → nf-executor.md} +14 -14
- package/agents/{qgsd-integration-checker.md → nf-integration-checker.md} +1 -1
- package/agents/{qgsd-phase-researcher.md → nf-phase-researcher.md} +6 -6
- package/agents/{qgsd-plan-checker.md → nf-plan-checker.md} +9 -9
- package/agents/{qgsd-planner.md → nf-planner.md} +9 -9
- package/agents/{qgsd-project-researcher.md → nf-project-researcher.md} +2 -2
- package/agents/{qgsd-quorum-orchestrator.md → nf-quorum-orchestrator.md} +33 -33
- package/agents/{qgsd-quorum-slot-worker.md → nf-quorum-slot-worker.md} +3 -3
- package/agents/{qgsd-quorum-synthesizer.md → nf-quorum-synthesizer.md} +3 -3
- package/agents/{qgsd-quorum-test-worker.md → nf-quorum-test-worker.md} +1 -1
- package/agents/{qgsd-quorum-worker.md → nf-quorum-worker.md} +6 -6
- package/agents/{qgsd-research-synthesizer.md → nf-research-synthesizer.md} +5 -5
- package/agents/{qgsd-roadmapper.md → nf-roadmapper.md} +3 -3
- package/agents/{qgsd-verifier.md → nf-verifier.md} +8 -8
- package/bin/accept-debug-invariant.cjs +2 -2
- package/bin/account-manager.cjs +10 -10
- package/bin/aggregate-requirements.cjs +1 -1
- package/bin/analyze-assumptions.cjs +3 -3
- package/bin/analyze-state-space.cjs +14 -14
- package/bin/assumption-register.cjs +146 -0
- package/bin/attribute-trace-divergence.cjs +1 -1
- package/bin/auth-drivers/gh-cli.cjs +1 -1
- package/bin/auth-drivers/pool.cjs +1 -1
- package/bin/autoClosePtoF.cjs +3 -3
- package/bin/budget-tracker.cjs +77 -0
- package/bin/build-layer-manifest.cjs +153 -0
- package/bin/call-quorum-slot.cjs +3 -3
- package/bin/ccr-secure-config.cjs +5 -5
- package/bin/check-bundled-sdks.cjs +1 -1
- package/bin/check-mcp-health.cjs +1 -1
- package/bin/check-provider-health.cjs +6 -6
- package/bin/check-spec-sync.cjs +26 -26
- package/bin/check-trace-schema-drift.cjs +5 -5
- package/bin/conformance-schema.cjs +2 -2
- package/bin/cross-layer-dashboard.cjs +297 -0
- package/bin/design-impact.cjs +377 -0
- package/bin/detect-coverage-gaps.cjs +7 -7
- package/bin/failure-mode-catalog.cjs +227 -0
- package/bin/failure-taxonomy.cjs +177 -0
- package/bin/formal-scope-scan.cjs +179 -0
- package/bin/gate-a-grounding.cjs +334 -0
- package/bin/gate-b-abstraction.cjs +243 -0
- package/bin/gate-c-validation.cjs +166 -0
- package/bin/generate-formal-specs.cjs +17 -17
- package/bin/generate-petri-net.cjs +3 -3
- package/bin/generate-tla-cfg.cjs +5 -5
- package/bin/git-heatmap.cjs +571 -0
- package/bin/harness-diagnostic.cjs +326 -0
- package/bin/hazard-model.cjs +261 -0
- package/bin/install-formal-tools.cjs +1 -1
- package/bin/install.js +184 -139
- package/bin/instrumentation-map.cjs +178 -0
- package/bin/invariant-catalog.cjs +437 -0
- package/bin/issue-classifier.cjs +2 -2
- package/bin/load-baseline-requirements.cjs +4 -4
- package/bin/manage-agents-core.cjs +32 -32
- package/bin/migrate-to-slots.cjs +39 -39
- package/bin/mismatch-register.cjs +217 -0
- package/bin/nForma.cjs +176 -81
- package/bin/{qgsd-solve.cjs → nf-solve.cjs} +327 -14
- package/bin/observe-config.cjs +8 -0
- package/bin/observe-debt-writer.cjs +1 -1
- package/bin/observe-handler-deps.cjs +356 -0
- package/bin/observe-handler-grafana.cjs +2 -17
- package/bin/observe-handler-internal.cjs +5 -5
- package/bin/observe-handler-logstash.cjs +2 -17
- package/bin/observe-handler-prometheus.cjs +2 -17
- package/bin/observe-handler-upstream.cjs +251 -0
- package/bin/observe-handlers.cjs +12 -33
- package/bin/observe-render.cjs +68 -22
- package/bin/observe-utils.cjs +37 -0
- package/bin/observed-fsm.cjs +324 -0
- package/bin/planning-paths.cjs +6 -0
- package/bin/polyrepo.cjs +1 -1
- package/bin/probe-quorum-slots.cjs +1 -1
- package/bin/promote-gate-maturity.cjs +274 -0
- package/bin/promote-model.cjs +1 -1
- package/bin/propose-debug-invariants.cjs +1 -1
- package/bin/quorum-cache.cjs +144 -0
- package/bin/quorum-consensus-gate.cjs +1 -1
- package/bin/quorum-preflight.cjs +89 -0
- package/bin/quorum-slot-dispatch.cjs +6 -6
- package/bin/requirements-core.cjs +1 -1
- package/bin/review-mcp-logs.cjs +1 -1
- package/bin/risk-heatmap.cjs +151 -0
- package/bin/run-account-manager-tlc.cjs +4 -4
- package/bin/run-account-pool-alloy.cjs +2 -2
- package/bin/run-alloy.cjs +2 -2
- package/bin/run-audit-alloy.cjs +2 -2
- package/bin/run-breaker-tlc.cjs +3 -3
- package/bin/run-formal-check.cjs +9 -9
- package/bin/run-formal-verify.cjs +30 -9
- package/bin/run-installer-alloy.cjs +2 -2
- package/bin/run-oscillation-tlc.cjs +4 -4
- package/bin/run-phase-tlc.cjs +1 -1
- package/bin/run-protocol-tlc.cjs +4 -4
- package/bin/run-quorum-composition-alloy.cjs +2 -2
- package/bin/run-sensitivity-sweep.cjs +2 -2
- package/bin/run-stop-hook-tlc.cjs +3 -3
- package/bin/run-tlc.cjs +21 -21
- package/bin/run-transcript-alloy.cjs +2 -2
- package/bin/secrets.cjs +5 -5
- package/bin/security-sweep.cjs +238 -0
- package/bin/sensitivity-report.cjs +3 -3
- package/bin/set-secret.cjs +5 -5
- package/bin/setup-telemetry-cron.sh +3 -3
- package/bin/stall-detector.cjs +126 -0
- package/bin/state-candidates.cjs +206 -0
- package/bin/sync-baseline-requirements.cjs +1 -1
- package/bin/telemetry-collector.cjs +1 -1
- package/bin/test-changed.cjs +111 -0
- package/bin/test-recipe-gen.cjs +250 -0
- package/bin/trace-corpus-stats.cjs +211 -0
- package/bin/unified-mcp-server.mjs +3 -3
- package/bin/update-scoreboard.cjs +1 -1
- package/bin/validate-memory.cjs +2 -2
- package/bin/validate-traces.cjs +10 -10
- package/bin/verify-quorum-health.cjs +66 -5
- package/bin/xstate-to-tla.cjs +4 -4
- package/bin/xstate-trace-walker.cjs +3 -3
- package/commands/{qgsd → nf}/add-phase.md +3 -3
- package/commands/{qgsd → nf}/add-requirement.md +3 -3
- package/commands/{qgsd → nf}/add-todo.md +3 -3
- package/commands/{qgsd → nf}/audit-milestone.md +4 -4
- package/commands/{qgsd → nf}/check-todos.md +3 -3
- package/commands/{qgsd → nf}/cleanup.md +3 -3
- package/commands/{qgsd → nf}/close-formal-gaps.md +2 -2
- package/commands/{qgsd → nf}/complete-milestone.md +9 -9
- package/commands/{qgsd → nf}/debug.md +9 -9
- package/commands/{qgsd → nf}/discuss-phase.md +3 -3
- package/commands/{qgsd → nf}/execute-phase.md +15 -15
- package/commands/{qgsd → nf}/fix-tests.md +3 -3
- package/commands/{qgsd → nf}/formal-test-sync.md +1 -1
- package/commands/{qgsd → nf}/health.md +3 -3
- package/commands/{qgsd → nf}/help.md +3 -3
- package/commands/{qgsd → nf}/insert-phase.md +3 -3
- package/commands/nf/join-discord.md +18 -0
- package/commands/{qgsd → nf}/list-phase-assumptions.md +2 -2
- package/commands/{qgsd → nf}/map-codebase.md +7 -7
- package/commands/{qgsd → nf}/map-requirements.md +3 -3
- package/commands/{qgsd → nf}/mcp-restart.md +3 -3
- package/commands/{qgsd → nf}/mcp-set-model.md +8 -8
- package/commands/{qgsd → nf}/mcp-setup.md +63 -63
- package/commands/{qgsd → nf}/mcp-status.md +3 -3
- package/commands/{qgsd → nf}/mcp-update.md +7 -7
- package/commands/{qgsd → nf}/new-milestone.md +8 -8
- package/commands/{qgsd → nf}/new-project.md +8 -8
- package/commands/{qgsd → nf}/observe.md +49 -16
- package/commands/{qgsd → nf}/pause-work.md +3 -3
- package/commands/{qgsd → nf}/plan-milestone-gaps.md +5 -5
- package/commands/{qgsd → nf}/plan-phase.md +6 -6
- package/commands/{qgsd → nf}/polyrepo.md +2 -2
- package/commands/{qgsd → nf}/progress.md +3 -3
- package/commands/{qgsd → nf}/queue.md +2 -2
- package/commands/{qgsd → nf}/quick.md +8 -8
- package/commands/{qgsd → nf}/quorum-test.md +10 -10
- package/commands/{qgsd → nf}/quorum.md +36 -86
- package/commands/{qgsd → nf}/reapply-patches.md +2 -2
- package/commands/{qgsd → nf}/remove-phase.md +3 -3
- package/commands/{qgsd → nf}/research-phase.md +12 -12
- package/commands/{qgsd → nf}/resume-work.md +3 -3
- package/commands/nf/review-requirements.md +31 -0
- package/commands/{qgsd → nf}/set-profile.md +3 -3
- package/commands/{qgsd → nf}/settings.md +6 -6
- package/commands/{qgsd → nf}/solve.md +35 -35
- package/commands/{qgsd → nf}/sync-baselines.md +4 -4
- package/commands/{qgsd → nf}/triage.md +10 -10
- package/commands/{qgsd → nf}/update.md +3 -3
- package/commands/{qgsd → nf}/verify-work.md +5 -5
- package/hooks/dist/config-loader.js +188 -32
- package/hooks/dist/conformance-schema.cjs +2 -2
- package/hooks/dist/gsd-context-monitor.js +118 -13
- package/hooks/dist/{qgsd-check-update.js → nf-check-update.js} +5 -5
- package/hooks/dist/{qgsd-circuit-breaker.js → nf-circuit-breaker.js} +35 -24
- package/hooks/dist/{qgsd-precompact.js → nf-precompact.js} +13 -13
- package/hooks/dist/{qgsd-prompt.js → nf-prompt.js} +110 -33
- package/hooks/dist/nf-session-start.js +185 -0
- package/hooks/dist/{qgsd-slot-correlator.js → nf-slot-correlator.js} +13 -5
- package/hooks/dist/{qgsd-spec-regen.js → nf-spec-regen.js} +17 -8
- package/hooks/dist/{qgsd-statusline.js → nf-statusline.js} +12 -3
- package/hooks/dist/{qgsd-stop.js → nf-stop.js} +152 -18
- package/hooks/dist/{qgsd-token-collector.js → nf-token-collector.js} +12 -4
- package/hooks/dist/unified-mcp-server.mjs +2 -2
- package/package.json +6 -4
- package/scripts/build-hooks.js +13 -6
- package/scripts/secret-audit.sh +1 -1
- package/scripts/verify-hooks-sync.cjs +90 -0
- package/templates/{qgsd.json → nf.json} +4 -4
- package/commands/qgsd/join-discord.md +0 -18
- package/hooks/dist/qgsd-session-start.js +0 -122
|
@@ -22,7 +22,7 @@ const REPORT_MD_PATH = process.env.SENSITIVITY_MD_PATH ||
|
|
|
22
22
|
|
|
23
23
|
const PARAM_ANNOTATIONS = {
|
|
24
24
|
MaxSize: {
|
|
25
|
-
codePath: 'hooks/
|
|
25
|
+
codePath: 'hooks/nf-prompt.js FAN_OUT_COUNT; .planning/formal/tla/MCsafety.cfg MaxSize',
|
|
26
26
|
testCases: [
|
|
27
27
|
'Test quorum at N=2 boundary: set FAN_OUT_COUNT=2 in providers.json and run quorum round',
|
|
28
28
|
'Test quorum at N=1 (no quorum): verify workflow rejects insufficient available slots',
|
|
@@ -44,7 +44,7 @@ const PARAM_ANNOTATIONS = {
|
|
|
44
44
|
],
|
|
45
45
|
},
|
|
46
46
|
MaxDeliberation: {
|
|
47
|
-
codePath: '.planning/formal/tla/MCsafety.cfg MaxDeliberation=7; src/machines/
|
|
47
|
+
codePath: '.planning/formal/tla/MCsafety.cfg MaxDeliberation=7; src/machines/nf-workflow.machine.ts MaxDeliberation guard',
|
|
48
48
|
testCases: [
|
|
49
49
|
'Test quorum workflow with max deliberation rounds reached — verify DECIDED fallback',
|
|
50
50
|
'Test rapid-fire slot responses (all within 1 deliberation round)',
|
|
@@ -89,7 +89,7 @@ function parseNDJSON(filePath) {
|
|
|
89
89
|
function generateReport(records) {
|
|
90
90
|
const now = new Date().toISOString();
|
|
91
91
|
const lines = [
|
|
92
|
-
'# Sensitivity Report —
|
|
92
|
+
'# Sensitivity Report — nForma v0.20',
|
|
93
93
|
'',
|
|
94
94
|
'Generated: ' + now,
|
|
95
95
|
'Source: .planning/formal/sensitivity-report.ndjson (' + records.length + ' records)',
|
package/bin/set-secret.cjs
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* set-secret.cjs
|
|
5
5
|
* Usage: node bin/set-secret.cjs <KEY_NAME> <value>
|
|
6
6
|
*
|
|
7
|
-
* Stores KEY_NAME=value in the OS keychain under service "
|
|
8
|
-
* then syncs all
|
|
7
|
+
* Stores KEY_NAME=value in the OS keychain under service "nforma",
|
|
8
|
+
* then syncs all nforma secrets into ~/.claude.json mcpServers env blocks.
|
|
9
9
|
*/
|
|
10
10
|
const { set, syncToClaudeJson, SERVICE } = require('./secrets.cjs');
|
|
11
11
|
|
|
@@ -19,11 +19,11 @@ const value = valueParts.join(' ');
|
|
|
19
19
|
(async () => {
|
|
20
20
|
try {
|
|
21
21
|
await set(SERVICE, keyName, value);
|
|
22
|
-
console.log(`[
|
|
22
|
+
console.log(`[nf] Stored ${keyName} in keychain (service: ${SERVICE})`);
|
|
23
23
|
await syncToClaudeJson(SERVICE);
|
|
24
|
-
console.log('[
|
|
24
|
+
console.log('[nf] Synced keychain secrets to ~/.claude.json');
|
|
25
25
|
} catch (e) {
|
|
26
|
-
console.error('[
|
|
26
|
+
console.error('[nf] Error:', e.message);
|
|
27
27
|
process.exit(1);
|
|
28
28
|
}
|
|
29
29
|
})();
|
|
@@ -26,11 +26,11 @@ if crontab -l 2>/dev/null | grep -q "telemetry-collector"; then
|
|
|
26
26
|
fi
|
|
27
27
|
|
|
28
28
|
# Install cron entry: top of every hour
|
|
29
|
-
(crontab -l 2>/dev/null; echo "0 * * * * $CRON_CMD >> /tmp/
|
|
29
|
+
(crontab -l 2>/dev/null; echo "0 * * * * $CRON_CMD >> /tmp/nf-telemetry.log 2>&1") | crontab -
|
|
30
30
|
|
|
31
31
|
echo "Telemetry cron installed."
|
|
32
32
|
|
|
33
33
|
# Windows: use Task Scheduler. Create a Basic Task that runs:
|
|
34
|
-
# node C:\path\to\
|
|
35
|
-
# followed by: node C:\path\to\
|
|
34
|
+
# node C:\path\to\nforma\bin\telemetry-collector.cjs
|
|
35
|
+
# followed by: node C:\path\to\nforma\bin\issue-classifier.cjs
|
|
36
36
|
# Trigger: Daily, repeat every 1 hour indefinitely.
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// bin/stall-detector.cjs
|
|
3
|
+
// Stall detection for quorum slots — INFORMATIONAL ONLY, never blocks.
|
|
4
|
+
// Uses only node:fs, node:path, node:child_process. No external dependencies.
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { spawnSync } = require('child_process');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Detect stalled quorum slots from quorum-failures.json.
|
|
12
|
+
* @param {string} cwd - Project working directory.
|
|
13
|
+
* @param {object} config - Config object (needs stall_detection section).
|
|
14
|
+
* @returns {Array} Array of { slot, consecutiveTimeouts, lastSeen }.
|
|
15
|
+
*/
|
|
16
|
+
function detectStalledSlots(cwd, config) {
|
|
17
|
+
try {
|
|
18
|
+
let failuresPath;
|
|
19
|
+
try {
|
|
20
|
+
const planningPaths = require(path.join(__dirname, 'planning-paths.cjs'));
|
|
21
|
+
failuresPath = planningPaths.resolveWithFallback(cwd, 'quorum-failures');
|
|
22
|
+
} catch {
|
|
23
|
+
failuresPath = path.join(cwd, '.planning', 'quorum', 'failures.json');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (!fs.existsSync(failuresPath)) return [];
|
|
27
|
+
|
|
28
|
+
const data = JSON.parse(fs.readFileSync(failuresPath, 'utf8'));
|
|
29
|
+
if (!Array.isArray(data) || data.length === 0) return [];
|
|
30
|
+
|
|
31
|
+
// Group records by slot
|
|
32
|
+
const bySlot = {};
|
|
33
|
+
for (const record of data) {
|
|
34
|
+
const slot = record.slot || record.slot_name || 'unknown';
|
|
35
|
+
if (!bySlot[slot]) bySlot[slot] = [];
|
|
36
|
+
bySlot[slot].push(record);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const results = [];
|
|
40
|
+
for (const [slot, records] of Object.entries(bySlot)) {
|
|
41
|
+
// Count consecutive TIMEOUT entries from the end
|
|
42
|
+
let consecutive = 0;
|
|
43
|
+
let lastSeen = null;
|
|
44
|
+
for (let i = records.length - 1; i >= 0; i--) {
|
|
45
|
+
const r = records[i];
|
|
46
|
+
const reason = (r.reason || r.type || '').toUpperCase();
|
|
47
|
+
if (reason === 'TIMEOUT') {
|
|
48
|
+
consecutive++;
|
|
49
|
+
if (!lastSeen) lastSeen = r.ts || r.timestamp || null;
|
|
50
|
+
} else {
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (consecutive >= 1) {
|
|
55
|
+
results.push({ slot, consecutiveTimeouts: consecutive, lastSeen });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return results;
|
|
60
|
+
} catch {
|
|
61
|
+
return []; // Fail-open
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Determine if stall should be escalated to user.
|
|
67
|
+
* @param {Array} stalledSlots - Output from detectStalledSlots.
|
|
68
|
+
* @param {object} config - Config object.
|
|
69
|
+
* @param {string} cwd - Project working directory.
|
|
70
|
+
* @returns {object} Escalation result.
|
|
71
|
+
*/
|
|
72
|
+
function shouldEscalate(stalledSlots, config, cwd) {
|
|
73
|
+
const stallCfg = (config && config.stall_detection) || {};
|
|
74
|
+
const threshold = stallCfg.consecutive_threshold || 2;
|
|
75
|
+
|
|
76
|
+
const filtered = stalledSlots.filter(s => s.consecutiveTimeouts >= threshold);
|
|
77
|
+
|
|
78
|
+
if (filtered.length === 0) {
|
|
79
|
+
return { escalate: false, reason: 'below_threshold', stalledSlots: [] };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Check for recent commit activity
|
|
83
|
+
if (stallCfg.check_commits !== false) {
|
|
84
|
+
try {
|
|
85
|
+
const result = spawnSync('git', ['rev-list', '--count', 'HEAD', '--since=10 minutes ago'], {
|
|
86
|
+
cwd: cwd || process.cwd(),
|
|
87
|
+
timeout: 3000,
|
|
88
|
+
encoding: 'utf8',
|
|
89
|
+
});
|
|
90
|
+
const count = parseInt(result.stdout.trim(), 10);
|
|
91
|
+
if (count > 0) {
|
|
92
|
+
return { escalate: false, reason: 'commits_active', stalledSlots: filtered };
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
// Fail-open: if git fails, proceed with escalation check
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { escalate: true, stalledSlots: filtered };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Format a structured stall report for additionalContext injection.
|
|
104
|
+
* @param {object} escalationResult - Output from shouldEscalate.
|
|
105
|
+
* @returns {object|null} Stall report or null.
|
|
106
|
+
*/
|
|
107
|
+
function formatStallReport(escalationResult) {
|
|
108
|
+
if (!escalationResult || !escalationResult.escalate) return null;
|
|
109
|
+
|
|
110
|
+
const slotNames = escalationResult.stalledSlots.map(s => s.slot).join(', ');
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
type: 'stall_report',
|
|
114
|
+
ts: new Date().toISOString(),
|
|
115
|
+
stalled_slots: escalationResult.stalledSlots.map(s => ({
|
|
116
|
+
slot: s.slot,
|
|
117
|
+
consecutive_timeouts: s.consecutiveTimeouts,
|
|
118
|
+
last_seen: s.lastSeen,
|
|
119
|
+
recommendation: 'Check provider health: node bin/check-mcp-health.cjs',
|
|
120
|
+
})),
|
|
121
|
+
recommendation: 'Stalled slots detected with no new commits. Consider checking provider status or adjusting quorum composition.',
|
|
122
|
+
message: `STALL DETECTED: ${escalationResult.stalledSlots.length} slot(s) stalled (${slotNames}). Run \`node bin/check-mcp-health.cjs\` to diagnose.`,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = { detectStalledSlots, shouldEscalate, formatStallReport };
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// bin/state-candidates.cjs
|
|
4
|
+
// Mines conformance traces for unmodeled state candidates.
|
|
5
|
+
// Identifies unmapped actions and suggests missing state transitions.
|
|
6
|
+
//
|
|
7
|
+
// Requirement: EVID-04
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
|
|
12
|
+
const ROOT = process.env.PROJECT_ROOT || path.join(__dirname, '..');
|
|
13
|
+
const EVIDENCE_DIR = path.join(ROOT, '.planning', 'formal', 'evidence');
|
|
14
|
+
const VOCAB_PATH = path.join(EVIDENCE_DIR, 'event-vocabulary.json');
|
|
15
|
+
const OUTPUT_PATH = path.join(EVIDENCE_DIR, 'state-candidates.json');
|
|
16
|
+
|
|
17
|
+
const JSON_FLAG = process.argv.includes('--json');
|
|
18
|
+
|
|
19
|
+
// ── Main ────────────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
function main() {
|
|
22
|
+
if (!fs.existsSync(EVIDENCE_DIR)) {
|
|
23
|
+
fs.mkdirSync(EVIDENCE_DIR, { recursive: true });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Import mapToXStateEvent from validate-traces.cjs
|
|
27
|
+
const { mapToXStateEvent } = require('./validate-traces.cjs');
|
|
28
|
+
|
|
29
|
+
// Read conformance events using planning-paths
|
|
30
|
+
const pp = require('./planning-paths.cjs');
|
|
31
|
+
const eventsPath = pp.resolve(process.cwd(), 'conformance-events');
|
|
32
|
+
|
|
33
|
+
if (!fs.existsSync(eventsPath)) {
|
|
34
|
+
console.error(`Conformance events file not found: ${eventsPath}`);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const raw = fs.readFileSync(eventsPath, 'utf8');
|
|
39
|
+
const lines = raw.split('\n').filter(l => l.trim());
|
|
40
|
+
const events = [];
|
|
41
|
+
for (const line of lines) {
|
|
42
|
+
try {
|
|
43
|
+
events.push(JSON.parse(line));
|
|
44
|
+
} catch (_) {}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Load vocabulary
|
|
48
|
+
const vocab = JSON.parse(fs.readFileSync(VOCAB_PATH, 'utf8'));
|
|
49
|
+
const vocabActions = new Set(Object.keys(vocab.vocabulary));
|
|
50
|
+
|
|
51
|
+
// Sort by timestamp
|
|
52
|
+
events.sort((a, b) => {
|
|
53
|
+
const tsA = new Date(a.ts || a.timestamp).getTime();
|
|
54
|
+
const tsB = new Date(b.ts || b.timestamp).getTime();
|
|
55
|
+
return tsA - tsB;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Identify unmapped actions: action is "undefined", not in vocabulary, or has no action field
|
|
59
|
+
const unmappedClusters = {};
|
|
60
|
+
let totalUnmapped = 0;
|
|
61
|
+
|
|
62
|
+
for (let i = 0; i < events.length; i++) {
|
|
63
|
+
const event = events[i];
|
|
64
|
+
const action = event.action || event.type || 'undefined';
|
|
65
|
+
|
|
66
|
+
// Check if this action is unmapped
|
|
67
|
+
const isUnmapped = !vocabActions.has(action);
|
|
68
|
+
|
|
69
|
+
if (isUnmapped) {
|
|
70
|
+
totalUnmapped++;
|
|
71
|
+
|
|
72
|
+
if (!unmappedClusters[action]) {
|
|
73
|
+
unmappedClusters[action] = {
|
|
74
|
+
action,
|
|
75
|
+
count: 0,
|
|
76
|
+
timestamps: [],
|
|
77
|
+
context_before: {},
|
|
78
|
+
context_after: {},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const cluster = unmappedClusters[action];
|
|
83
|
+
cluster.count++;
|
|
84
|
+
|
|
85
|
+
// Sample timestamps (first 5)
|
|
86
|
+
if (cluster.timestamps.length < 5) {
|
|
87
|
+
cluster.timestamps.push(event.ts || event.timestamp);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Context: what actions appear before/after
|
|
91
|
+
if (i > 0) {
|
|
92
|
+
const prevAction = events[i - 1].action || events[i - 1].type || 'undefined';
|
|
93
|
+
cluster.context_before[prevAction] = (cluster.context_before[prevAction] || 0) + 1;
|
|
94
|
+
}
|
|
95
|
+
if (i < events.length - 1) {
|
|
96
|
+
const nextAction = events[i + 1].action || events[i + 1].type || 'undefined';
|
|
97
|
+
cluster.context_after[nextAction] = (cluster.context_after[nextAction] || 0) + 1;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Build deduplicated candidates
|
|
103
|
+
const candidates = Object.values(unmappedClusters).map(cluster => {
|
|
104
|
+
// Suggest state variable based on context
|
|
105
|
+
const topBefore = Object.entries(cluster.context_before)
|
|
106
|
+
.sort((a, b) => b[1] - a[1])[0];
|
|
107
|
+
const topAfter = Object.entries(cluster.context_after)
|
|
108
|
+
.sort((a, b) => b[1] - a[1])[0];
|
|
109
|
+
|
|
110
|
+
let suggestedState = 'unknown';
|
|
111
|
+
let confidence = 'low';
|
|
112
|
+
|
|
113
|
+
if (topBefore && topBefore[0] === 'quorum_start') {
|
|
114
|
+
suggestedState = 'quorum_sub_state';
|
|
115
|
+
confidence = 'medium';
|
|
116
|
+
} else if (topAfter && topAfter[0] === 'quorum_complete') {
|
|
117
|
+
suggestedState = 'quorum_pre_completion';
|
|
118
|
+
confidence = 'medium';
|
|
119
|
+
} else if (cluster.action === 'undefined' || cluster.action === 'quorum_fallback_t1_required') {
|
|
120
|
+
suggestedState = 'instrumentation_gap';
|
|
121
|
+
confidence = 'high';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const allTs = cluster.timestamps.map(t => new Date(t).getTime()).filter(t => !isNaN(t));
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
action: cluster.action,
|
|
128
|
+
count: cluster.count,
|
|
129
|
+
first_seen: allTs.length > 0 ? new Date(Math.min(...allTs)).toISOString() : null,
|
|
130
|
+
last_seen: allTs.length > 0 ? new Date(Math.max(...allTs)).toISOString() : null,
|
|
131
|
+
sample_timestamps: cluster.timestamps.slice(0, 5),
|
|
132
|
+
context_before: cluster.context_before,
|
|
133
|
+
context_after: cluster.context_after,
|
|
134
|
+
suggested_state: suggestedState,
|
|
135
|
+
confidence,
|
|
136
|
+
};
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Sort by count descending
|
|
140
|
+
candidates.sort((a, b) => b.count - a.count);
|
|
141
|
+
|
|
142
|
+
// Identify missing transitions (sequences of known actions not in XState)
|
|
143
|
+
const transitionPairs = {};
|
|
144
|
+
for (let i = 0; i < events.length - 1; i++) {
|
|
145
|
+
const fromAction = events[i].action || 'undefined';
|
|
146
|
+
const toAction = events[i + 1].action || 'undefined';
|
|
147
|
+
if (vocabActions.has(fromAction) && vocabActions.has(toAction)) {
|
|
148
|
+
const key = `${fromAction}→${toAction}`;
|
|
149
|
+
transitionPairs[key] = (transitionPairs[key] || 0) + 1;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Try mapping through XState to find unmodeled transitions
|
|
154
|
+
const missingTransitions = [];
|
|
155
|
+
for (const [key, count] of Object.entries(transitionPairs)) {
|
|
156
|
+
const [fromAction, toAction] = key.split('→');
|
|
157
|
+
// Check if XState maps both actions
|
|
158
|
+
try {
|
|
159
|
+
const fromEvent = mapToXStateEvent({ action: fromAction });
|
|
160
|
+
const toEvent = mapToXStateEvent({ action: toAction });
|
|
161
|
+
if (!fromEvent || !toEvent) {
|
|
162
|
+
missingTransitions.push({
|
|
163
|
+
from_action: fromAction,
|
|
164
|
+
to_action: toAction,
|
|
165
|
+
count,
|
|
166
|
+
note: `XState mapping missing: from=${fromEvent || 'null'}, to=${toEvent || 'null'}`,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
} catch (_) {}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Build result
|
|
173
|
+
const result = {
|
|
174
|
+
schema_version: '1',
|
|
175
|
+
generated: new Date().toISOString(),
|
|
176
|
+
total_unmapped_events: totalUnmapped,
|
|
177
|
+
candidates,
|
|
178
|
+
missing_transitions: missingTransitions,
|
|
179
|
+
summary: `${totalUnmapped} unmapped events found. ` +
|
|
180
|
+
`${candidates.length} candidate(s): ` +
|
|
181
|
+
candidates.slice(0, 3).map(c => `${c.action} (${c.count}x)`).join(', ') +
|
|
182
|
+
(candidates.length > 3 ? `, +${candidates.length - 3} more` : '') + '. ' +
|
|
183
|
+
`${missingTransitions.length} potentially missing transitions.`,
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
fs.writeFileSync(OUTPUT_PATH, JSON.stringify(result, null, 2) + '\n', 'utf8');
|
|
187
|
+
|
|
188
|
+
if (JSON_FLAG) {
|
|
189
|
+
console.log(JSON.stringify(result, null, 2));
|
|
190
|
+
} else {
|
|
191
|
+
console.log('State Candidates Generated');
|
|
192
|
+
console.log(` Unmapped events: ${totalUnmapped}`);
|
|
193
|
+
console.log(` Candidates: ${candidates.length}`);
|
|
194
|
+
for (const c of candidates.slice(0, 5)) {
|
|
195
|
+
console.log(` ${c.action}: ${c.count}x (suggested: ${c.suggested_state}, confidence: ${c.confidence})`);
|
|
196
|
+
}
|
|
197
|
+
console.log(` Missing transitions: ${missingTransitions.length}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Export for testing
|
|
202
|
+
module.exports = { main };
|
|
203
|
+
|
|
204
|
+
if (require.main === module) {
|
|
205
|
+
main();
|
|
206
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Run only tests affected by changed source files.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* node bin/test-changed.cjs # changes vs HEAD (staged + unstaged)
|
|
9
|
+
* node bin/test-changed.cjs --since=main # changes vs main branch
|
|
10
|
+
* node bin/test-changed.cjs --since=HEAD~3 # changes in last 3 commits
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const { execFileSync, spawn } = require('child_process');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
|
|
17
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
18
|
+
|
|
19
|
+
// ── Parse args ──────────────────────────────────────────────────────────
|
|
20
|
+
const args = process.argv.slice(2);
|
|
21
|
+
const sinceFlag = args.find(a => a.startsWith('--since='));
|
|
22
|
+
const since = sinceFlag ? sinceFlag.split('=')[1] : null;
|
|
23
|
+
const dryRun = args.includes('--dry-run');
|
|
24
|
+
const verbose = args.includes('--verbose');
|
|
25
|
+
|
|
26
|
+
// ── 1. Get changed files ───────────────────────────────────────────────
|
|
27
|
+
function getChangedFiles() {
|
|
28
|
+
const files = new Set();
|
|
29
|
+
|
|
30
|
+
if (since) {
|
|
31
|
+
const committed = execFileSync('git', ['diff', '--name-only', `${since}...HEAD`], { cwd: ROOT, encoding: 'utf8' });
|
|
32
|
+
committed.trim().split('\n').filter(Boolean).forEach(f => files.add(f));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Always include unstaged + staged changes
|
|
36
|
+
const unstaged = execFileSync('git', ['diff', '--name-only'], { cwd: ROOT, encoding: 'utf8' });
|
|
37
|
+
const staged = execFileSync('git', ['diff', '--name-only', '--cached'], { cwd: ROOT, encoding: 'utf8' });
|
|
38
|
+
|
|
39
|
+
unstaged.trim().split('\n').filter(Boolean).forEach(f => files.add(f));
|
|
40
|
+
staged.trim().split('\n').filter(Boolean).forEach(f => files.add(f));
|
|
41
|
+
|
|
42
|
+
return [...files];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── 2. Map source files → test files ────────────────────────────────────
|
|
46
|
+
function mapToTestFiles(changedFiles) {
|
|
47
|
+
const testFiles = new Set();
|
|
48
|
+
|
|
49
|
+
for (const file of changedFiles) {
|
|
50
|
+
// Skip non-JS files
|
|
51
|
+
if (!/\.(cjs|mjs|js)$/.test(file)) continue;
|
|
52
|
+
|
|
53
|
+
// If the file IS a test file, include it directly
|
|
54
|
+
if (/\.test\.(cjs|mjs|js)$/.test(file)) {
|
|
55
|
+
const abs = path.resolve(ROOT, file);
|
|
56
|
+
if (fs.existsSync(abs)) testFiles.add(abs);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Map source → test: foo.cjs → foo.test.cjs
|
|
61
|
+
const ext = path.extname(file);
|
|
62
|
+
const base = file.slice(0, -ext.length);
|
|
63
|
+
const testPath = `${base}.test${ext}`;
|
|
64
|
+
const abs = path.resolve(ROOT, testPath);
|
|
65
|
+
if (fs.existsSync(abs)) {
|
|
66
|
+
testFiles.add(abs);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Also check .test.cjs variant for .js sources
|
|
70
|
+
if (ext !== '.cjs') {
|
|
71
|
+
const altPath = `${base}.test.cjs`;
|
|
72
|
+
const altAbs = path.resolve(ROOT, altPath);
|
|
73
|
+
if (fs.existsSync(altAbs)) testFiles.add(altAbs);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return [...testFiles].sort();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── 3. Run ──────────────────────────────────────────────────────────────
|
|
81
|
+
const changed = getChangedFiles();
|
|
82
|
+
if (verbose) {
|
|
83
|
+
console.log(`Changed files (since ${since || 'working tree'}):`);
|
|
84
|
+
changed.forEach(f => console.log(` ${f}`));
|
|
85
|
+
console.log();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const tests = mapToTestFiles(changed);
|
|
89
|
+
|
|
90
|
+
if (tests.length === 0) {
|
|
91
|
+
console.log('No affected test files found.');
|
|
92
|
+
process.exit(0);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
console.log(`Running ${tests.length} affected test(s):`);
|
|
96
|
+
tests.forEach(t => console.log(` ${path.relative(ROOT, t)}`));
|
|
97
|
+
console.log();
|
|
98
|
+
|
|
99
|
+
if (dryRun) {
|
|
100
|
+
console.log('(dry-run mode — skipping execution)');
|
|
101
|
+
process.exit(0);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const child = spawn('node', ['--test', ...tests], {
|
|
105
|
+
cwd: ROOT,
|
|
106
|
+
stdio: 'inherit',
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
child.on('close', (code) => {
|
|
110
|
+
process.exit(code ?? 1);
|
|
111
|
+
});
|