codeharness 0.20.0 → 0.21.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.
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Types for the observability analyzer module.
3
+ *
4
+ * These interfaces are tool-agnostic: any static analysis tool
5
+ * (Semgrep, ESLint, custom) can produce conforming output.
6
+ * See architecture Decision 1 for rationale.
7
+ */
8
+ /** Severity levels for observability gaps */
9
+ type GapSeverity = 'error' | 'warning' | 'info';
10
+ /**
11
+ * A single observability gap found by static analysis.
12
+ *
13
+ * Maps 1:1 from analyzer output. The `type` field carries the
14
+ * rule ID from the analyzer (e.g., "catch-without-logging"),
15
+ * NOT a Semgrep-specific identifier.
16
+ */
17
+ interface ObservabilityGap {
18
+ /** File path where the gap was found */
19
+ readonly file: string;
20
+ /** Line number in the file */
21
+ readonly line: number;
22
+ /** Rule identifier from the analyzer */
23
+ readonly type: string;
24
+ /** Human-readable description of the gap */
25
+ readonly description: string;
26
+ /** Severity of the gap */
27
+ readonly severity: GapSeverity;
28
+ }
29
+ /**
30
+ * Summary statistics from an analysis run.
31
+ */
32
+ interface AnalyzerSummary {
33
+ /** Total number of functions scanned */
34
+ readonly totalFunctions: number;
35
+ /** Number of functions that have log statements */
36
+ readonly functionsWithLogs: number;
37
+ /** Number of error handlers without logging */
38
+ readonly errorHandlersWithoutLogs: number;
39
+ /** Coverage percentage: functionsWithLogs / totalFunctions * 100 */
40
+ readonly coveragePercent: number;
41
+ /** Distribution of log levels found (e.g., { debug: 5, error: 3 }) */
42
+ readonly levelDistribution: Record<string, number>;
43
+ }
44
+ /**
45
+ * Configuration for the analyzer module.
46
+ *
47
+ * Allows callers to specify which tool to use and where rules live.
48
+ * Defaults are applied when not provided.
49
+ */
50
+ interface AnalyzerConfig {
51
+ /** The analysis tool to use. Default: 'semgrep' */
52
+ readonly tool?: string;
53
+ /** Directory containing analysis rules, relative to project root. Default: 'patches/observability/' */
54
+ readonly rulesDir?: string;
55
+ /** Timeout for the analysis subprocess in milliseconds. Default: 60000 */
56
+ readonly timeout?: number;
57
+ /**
58
+ * Total number of functions in the project (from an external count or AST scan).
59
+ * When provided, enables accurate coverage computation:
60
+ * coveragePercent = (totalFunctions - functionsWithoutLogs) / totalFunctions * 100.
61
+ * When omitted, totalFunctions defaults to the count of function-no-debug-log matches
62
+ * (functions lacking logs), producing 0% coverage as the best conservative estimate.
63
+ */
64
+ readonly totalFunctions?: number;
65
+ }
66
+ /**
67
+ * The result of an analysis run, independent of the underlying tool.
68
+ *
69
+ * Any static analysis tool that produces this shape can be used as
70
+ * a drop-in replacement for Semgrep.
71
+ */
72
+ interface AnalyzerResult {
73
+ /** The tool that produced this result (e.g., 'semgrep', 'eslint', 'custom') */
74
+ readonly tool: string;
75
+ /** Observability gaps found */
76
+ readonly gaps: ObservabilityGap[];
77
+ /** Summary statistics */
78
+ readonly summary: AnalyzerSummary;
79
+ /** Whether the analysis was skipped (e.g., tool not installed) */
80
+ readonly skipped?: boolean;
81
+ /** Reason the analysis was skipped, if applicable */
82
+ readonly skipReason?: string;
83
+ }
84
+ /** A single coverage history entry with timestamp */
85
+ interface CoverageHistoryEntry {
86
+ /** Coverage percentage at this point in time */
87
+ readonly coveragePercent: number;
88
+ /** ISO 8601 timestamp when this measurement was taken */
89
+ readonly timestamp: string;
90
+ }
91
+ /** Static analysis coverage state persisted in sprint-state.json */
92
+ interface StaticCoverageState {
93
+ /** Current coverage percentage */
94
+ readonly coveragePercent: number;
95
+ /** ISO 8601 timestamp of last scan */
96
+ readonly lastScanTimestamp: string;
97
+ /** Historical coverage entries */
98
+ readonly history: readonly CoverageHistoryEntry[];
99
+ }
100
+ /** Coverage targets configuration */
101
+ interface CoverageTargets {
102
+ /** Target coverage percentage for static analysis (default 80) */
103
+ readonly staticTarget: number;
104
+ /** Target coverage percentage for runtime observability (default 60) */
105
+ readonly runtimeTarget?: number;
106
+ }
107
+ /**
108
+ * Top-level observability coverage state in sprint-state.json.
109
+ *
110
+ * Contains `static` (Story 1.3) and optional `runtime` (Story 2.1) sections.
111
+ * Follows architecture Decision 2 (Separate Metrics).
112
+ */
113
+ interface ObservabilityCoverageState {
114
+ /** Static analysis coverage metrics */
115
+ readonly static: StaticCoverageState;
116
+ /** Coverage targets */
117
+ readonly targets: CoverageTargets;
118
+ /** Runtime observability coverage metrics (Story 2.1) */
119
+ readonly runtime?: RuntimeCoverageState;
120
+ }
121
+ /** A single AC's runtime coverage entry from verification */
122
+ interface RuntimeCoverageEntry {
123
+ /** Acceptance criterion identifier */
124
+ readonly acId: string;
125
+ /** Whether log events were detected for this AC */
126
+ readonly logEventsDetected: boolean;
127
+ /** Number of log events detected */
128
+ readonly logEventCount: number;
129
+ /** Gap note if no log events detected */
130
+ readonly gapNote?: string;
131
+ }
132
+ /** Result of computing runtime coverage from parsed observability gaps */
133
+ interface RuntimeCoverageResult {
134
+ /** Per-AC runtime coverage entries */
135
+ readonly entries: readonly RuntimeCoverageEntry[];
136
+ /** Total number of ACs evaluated */
137
+ readonly totalACs: number;
138
+ /** Number of ACs that produced log events */
139
+ readonly acsWithLogs: number;
140
+ /** Runtime coverage percentage: acsWithLogs / totalACs * 100 */
141
+ readonly coveragePercent: number;
142
+ }
143
+ /** Runtime coverage state persisted in sprint-state.json */
144
+ interface RuntimeCoverageState {
145
+ /** Runtime coverage percentage */
146
+ readonly coveragePercent: number;
147
+ /** ISO 8601 timestamp of last validation */
148
+ readonly lastValidationTimestamp: string;
149
+ /** Number of modules with telemetry */
150
+ readonly modulesWithTelemetry: number;
151
+ /** Total number of modules */
152
+ readonly totalModules: number;
153
+ /** Whether telemetry was detected at all */
154
+ readonly telemetryDetected: boolean;
155
+ }
156
+ /** Trend comparison between latest and previous coverage entries */
157
+ interface CoverageTrend {
158
+ /** Latest coverage percentage */
159
+ readonly current: number;
160
+ /** Previous coverage percentage, or null if only one entry */
161
+ readonly previous: number | null;
162
+ /** Delta: current - previous, or null if only one entry */
163
+ readonly delta: number | null;
164
+ /** Timestamp of latest measurement */
165
+ readonly currentTimestamp: string;
166
+ /** Timestamp of previous measurement, or null */
167
+ readonly previousTimestamp: string | null;
168
+ }
169
+ /** Result of checking coverage against a target */
170
+ interface CoverageTargetResult {
171
+ /** Whether coverage meets or exceeds the target */
172
+ readonly met: boolean;
173
+ /** Current coverage percentage */
174
+ readonly current: number;
175
+ /** Target coverage percentage */
176
+ readonly target: number;
177
+ /** Gap: target - current (0 if met) */
178
+ readonly gap: number;
179
+ }
180
+
181
+ /**
182
+ * Discriminated union Result type for consistent error handling.
183
+ * All module functions return Result<T> instead of throwing exceptions.
184
+ */
185
+ /** Successful result carrying data of type T */
186
+ interface Ok<T> {
187
+ readonly success: true;
188
+ readonly data: T;
189
+ }
190
+ /** Failed result carrying an error message and optional context */
191
+ interface Fail {
192
+ readonly success: false;
193
+ readonly error: string;
194
+ readonly context?: Record<string, unknown>;
195
+ }
196
+ /** Discriminated union: success field is the discriminant */
197
+ type Result<T> = Ok<T> | Fail;
198
+
199
+ /**
200
+ * Observability analyzer module — runs static analysis and produces
201
+ * a standardized gap report.
202
+ *
203
+ * Currently implements Semgrep integration but the AnalyzerResult
204
+ * interface is tool-agnostic: any tool producing the same shape
205
+ * can be used as a drop-in replacement.
206
+ */
207
+
208
+ /**
209
+ * Run observability analysis on a project directory.
210
+ *
211
+ * @param projectDir - Absolute path to the project root
212
+ * @param config - Optional configuration overriding defaults
213
+ * @returns Result containing the analysis report, or a skip result if the tool is not installed
214
+ */
215
+ declare function analyze(projectDir: string, config?: AnalyzerConfig): Result<AnalyzerResult>;
216
+
217
+ /**
218
+ * Coverage state persistence — tracks observability coverage in sprint-state.json.
219
+ *
220
+ * Implements Story 1.3: read/write/trend/target comparison for static analysis coverage.
221
+ * Only the `static` section is managed here; `runtime` is deferred to Epic 2.
222
+ */
223
+
224
+ /**
225
+ * Save coverage result from an analysis run into sprint-state.json.
226
+ *
227
+ * Updates `observability.static.coveragePercent`, `observability.static.lastScanTimestamp`,
228
+ * and appends to `observability.static.history`. Preserves all other state fields.
229
+ */
230
+ declare function saveCoverageResult(projectDir: string, result: AnalyzerResult): Result<void>;
231
+ /**
232
+ * Read the observability coverage state from sprint-state.json.
233
+ *
234
+ * Returns the typed `ObservabilityCoverageState` from the `observability` key.
235
+ * If no observability section exists, returns default state.
236
+ */
237
+ declare function readCoverageState(projectDir: string): Result<ObservabilityCoverageState>;
238
+ /**
239
+ * Get coverage trend by comparing latest vs previous history entries.
240
+ */
241
+ declare function getCoverageTrend(projectDir: string): Result<CoverageTrend>;
242
+ /**
243
+ * Check current coverage against a target threshold.
244
+ *
245
+ * @param projectDir - Project root directory
246
+ * @param target - Target percentage (default 80%)
247
+ * @returns Structured result indicating whether coverage meets the target
248
+ */
249
+ declare function checkCoverageTarget(projectDir: string, target?: number): Result<CoverageTargetResult>;
250
+
251
+ /** Per-AC observability gap presence */
252
+ interface ObservabilityGapEntry {
253
+ /** Acceptance criterion identifier */
254
+ readonly acId: string;
255
+ /** Whether an observability gap was detected */
256
+ readonly hasGap: boolean;
257
+ /** The gap note text, if present */
258
+ readonly gapNote?: string;
259
+ }
260
+ /** Result of parsing observability gaps from proof content */
261
+ interface ObservabilityGapResult {
262
+ /** Per-AC gap presence */
263
+ readonly entries: readonly ObservabilityGapEntry[];
264
+ /** Total number of ACs found in proof */
265
+ readonly totalACs: number;
266
+ /** Number of ACs with observability gaps */
267
+ readonly gapCount: number;
268
+ /** Number of ACs without gaps (produced log events) */
269
+ readonly coveredCount: number;
270
+ }
271
+
272
+ /**
273
+ * Runtime coverage computation — tracks observability coverage from verification proofs.
274
+ *
275
+ * Implements Story 2.1: computes runtime coverage from parsed observability gaps
276
+ * and persists results to sprint-state.json under `observability.runtime`.
277
+ * Separate from `coverage.ts` (static coverage) per architecture Decision 2.
278
+ */
279
+
280
+ /**
281
+ * Compute runtime coverage from parsed observability gap results.
282
+ *
283
+ * Coverage = acsWithLogs / totalACs * 100.
284
+ * Returns 0% coverage when totalACs is 0 (avoids division by zero).
285
+ */
286
+ declare function computeRuntimeCoverage(gapResults: ObservabilityGapResult): RuntimeCoverageResult;
287
+ /**
288
+ * Persist runtime coverage results to sprint-state.json under `observability.runtime`.
289
+ *
290
+ * Uses atomic write (write to temp, then rename) to prevent corruption.
291
+ * Preserves all existing state fields including static coverage data.
292
+ */
293
+ declare function saveRuntimeCoverage(projectDir: string, result: RuntimeCoverageResult): Result<void>;
294
+
295
+ export { type AnalyzerConfig, type AnalyzerResult, type AnalyzerSummary, type CoverageHistoryEntry, type CoverageTargetResult, type CoverageTargets, type CoverageTrend, type GapSeverity, type ObservabilityCoverageState, type ObservabilityGap, type RuntimeCoverageEntry, type RuntimeCoverageResult, type RuntimeCoverageState, type StaticCoverageState, analyze, checkCoverageTarget, computeRuntimeCoverage, getCoverageTrend, readCoverageState, saveCoverageResult, saveRuntimeCoverage };
@@ -0,0 +1,366 @@
1
+ // src/modules/observability/analyzer.ts
2
+ import { execFileSync } from "child_process";
3
+ import { join } from "path";
4
+
5
+ // src/types/result.ts
6
+ function ok(data) {
7
+ return { success: true, data };
8
+ }
9
+ function fail(error, context) {
10
+ if (context !== void 0) {
11
+ return { success: false, error, context };
12
+ }
13
+ return { success: false, error };
14
+ }
15
+
16
+ // src/modules/observability/analyzer.ts
17
+ var DEFAULT_RULES_DIR = "patches/observability/";
18
+ var DEFAULT_TIMEOUT = 6e4;
19
+ var FUNCTION_NO_LOG_RULE = "function-no-debug-log";
20
+ var CATCH_WITHOUT_LOGGING_RULE = "catch-without-logging";
21
+ var ERROR_PATH_NO_LOG_RULE = "error-path-no-log";
22
+ function matchesRule(gapType, ruleName) {
23
+ return gapType === ruleName || gapType.endsWith(`.${ruleName}`);
24
+ }
25
+ function analyze(projectDir, config) {
26
+ if (!projectDir || typeof projectDir !== "string") {
27
+ return fail("projectDir is required and must be a non-empty string");
28
+ }
29
+ const tool = config?.tool ?? "semgrep";
30
+ if (tool !== "semgrep") {
31
+ return fail(`Unsupported analyzer tool: ${tool}`);
32
+ }
33
+ if (!checkSemgrepInstalled()) {
34
+ return ok({
35
+ tool: "semgrep",
36
+ gaps: [],
37
+ summary: {
38
+ totalFunctions: 0,
39
+ functionsWithLogs: 0,
40
+ errorHandlersWithoutLogs: 0,
41
+ coveragePercent: 0,
42
+ levelDistribution: {}
43
+ },
44
+ skipped: true,
45
+ skipReason: "static analysis skipped -- install semgrep"
46
+ });
47
+ }
48
+ const rulesDir = config?.rulesDir ?? DEFAULT_RULES_DIR;
49
+ const timeout = config?.timeout ?? DEFAULT_TIMEOUT;
50
+ const fullRulesDir = join(projectDir, rulesDir);
51
+ const rawResult = runSemgrep(projectDir, fullRulesDir, timeout);
52
+ if (!rawResult.success) {
53
+ return fail(rawResult.error);
54
+ }
55
+ const gaps = parseSemgrepOutput(rawResult.data);
56
+ const summaryOpts = config?.totalFunctions != null ? { totalFunctions: config.totalFunctions } : void 0;
57
+ const summary = computeSummary(gaps, summaryOpts);
58
+ return ok({
59
+ tool: "semgrep",
60
+ gaps,
61
+ summary
62
+ });
63
+ }
64
+ function checkSemgrepInstalled() {
65
+ try {
66
+ execFileSync("semgrep", ["--version"], {
67
+ encoding: "utf-8",
68
+ timeout: 5e3,
69
+ stdio: "pipe"
70
+ });
71
+ return true;
72
+ } catch {
73
+ return false;
74
+ }
75
+ }
76
+ function runSemgrep(projectDir, rulesDir, timeout = DEFAULT_TIMEOUT) {
77
+ try {
78
+ const stdout = execFileSync(
79
+ "semgrep",
80
+ ["scan", "--config", rulesDir, "--json", projectDir],
81
+ { encoding: "utf-8", timeout, stdio: ["pipe", "pipe", "pipe"] }
82
+ );
83
+ const parsed = JSON.parse(stdout);
84
+ if (typeof parsed !== "object" || parsed === null || !Array.isArray(parsed.results)) {
85
+ return fail("Semgrep scan returned invalid JSON: missing results array");
86
+ }
87
+ return ok(parsed);
88
+ } catch (error) {
89
+ return fail(`Semgrep scan failed: ${String(error)}`);
90
+ }
91
+ }
92
+ function parseSemgrepOutput(raw) {
93
+ if (!raw.results || !Array.isArray(raw.results)) {
94
+ return [];
95
+ }
96
+ return raw.results.map((r) => ({
97
+ file: r.path,
98
+ line: r.start.line,
99
+ type: r.check_id,
100
+ description: r.extra.message,
101
+ severity: normalizeSeverity(r.extra.severity)
102
+ }));
103
+ }
104
+ function computeSummary(gaps, opts) {
105
+ const functionsWithoutLogs = gaps.filter(
106
+ (g) => matchesRule(g.type, FUNCTION_NO_LOG_RULE)
107
+ ).length;
108
+ const errorHandlersWithoutLogs = gaps.filter(
109
+ (g) => matchesRule(g.type, CATCH_WITHOUT_LOGGING_RULE) || matchesRule(g.type, ERROR_PATH_NO_LOG_RULE)
110
+ ).length;
111
+ const totalFunctions = opts?.totalFunctions ?? functionsWithoutLogs;
112
+ const functionsWithLogs = totalFunctions - functionsWithoutLogs;
113
+ const coveragePercent = totalFunctions === 0 ? 100 : Math.round(functionsWithLogs / totalFunctions * 100 * 100) / 100;
114
+ const levelDistribution = {};
115
+ for (const gap of gaps) {
116
+ levelDistribution[gap.severity] = (levelDistribution[gap.severity] ?? 0) + 1;
117
+ }
118
+ return {
119
+ totalFunctions,
120
+ functionsWithLogs,
121
+ errorHandlersWithoutLogs,
122
+ coveragePercent,
123
+ levelDistribution
124
+ };
125
+ }
126
+ function normalizeSeverity(severity) {
127
+ const lower = severity.toLowerCase();
128
+ if (lower === "error") return "error";
129
+ if (lower === "warning") return "warning";
130
+ return "info";
131
+ }
132
+
133
+ // src/modules/observability/coverage.ts
134
+ import { readFileSync, writeFileSync, renameSync, existsSync } from "fs";
135
+ import { join as join2 } from "path";
136
+ var STATE_FILE = "sprint-state.json";
137
+ var TMP_FILE = ".sprint-state.json.tmp";
138
+ var DEFAULT_STATIC_TARGET = 80;
139
+ var MAX_HISTORY_ENTRIES = 100;
140
+ function defaultCoverageState() {
141
+ return {
142
+ static: {
143
+ coveragePercent: 0,
144
+ lastScanTimestamp: "",
145
+ history: []
146
+ },
147
+ targets: {
148
+ staticTarget: DEFAULT_STATIC_TARGET
149
+ }
150
+ };
151
+ }
152
+ function readStateFile(projectDir) {
153
+ const fp = join2(projectDir, STATE_FILE);
154
+ if (!existsSync(fp)) {
155
+ return ok({});
156
+ }
157
+ try {
158
+ const raw = readFileSync(fp, "utf-8");
159
+ const parsed = JSON.parse(raw);
160
+ return ok(parsed);
161
+ } catch (err) {
162
+ const msg = err instanceof Error ? err.message : String(err);
163
+ return fail(`Failed to read ${STATE_FILE}: ${msg}`);
164
+ }
165
+ }
166
+ function writeStateAtomic(projectDir, state) {
167
+ try {
168
+ const data = JSON.stringify(state, null, 2) + "\n";
169
+ const tmp = join2(projectDir, TMP_FILE);
170
+ const final = join2(projectDir, STATE_FILE);
171
+ writeFileSync(tmp, data, "utf-8");
172
+ renameSync(tmp, final);
173
+ return ok(void 0);
174
+ } catch (err) {
175
+ const msg = err instanceof Error ? err.message : String(err);
176
+ return fail(`Failed to write ${STATE_FILE}: ${msg}`);
177
+ }
178
+ }
179
+ function saveCoverageResult(projectDir, result) {
180
+ if (!projectDir || typeof projectDir !== "string") {
181
+ return fail("projectDir is required and must be a non-empty string");
182
+ }
183
+ const stateResult = readStateFile(projectDir);
184
+ if (!stateResult.success) {
185
+ return fail(stateResult.error);
186
+ }
187
+ const state = stateResult.data;
188
+ const existing = extractCoverageState(state);
189
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
190
+ const fullHistory = [
191
+ ...existing.static.history,
192
+ { coveragePercent: result.summary.coveragePercent, timestamp }
193
+ ];
194
+ const trimmedHistory = fullHistory.length > MAX_HISTORY_ENTRIES ? fullHistory.slice(fullHistory.length - MAX_HISTORY_ENTRIES) : fullHistory;
195
+ const updatedStatic = {
196
+ coveragePercent: result.summary.coveragePercent,
197
+ lastScanTimestamp: timestamp,
198
+ history: trimmedHistory
199
+ };
200
+ const updatedObservability = {
201
+ static: updatedStatic,
202
+ targets: existing.targets
203
+ };
204
+ const updatedState = {
205
+ ...state,
206
+ observability: updatedObservability
207
+ };
208
+ return writeStateAtomic(projectDir, updatedState);
209
+ }
210
+ function readCoverageState(projectDir) {
211
+ if (!projectDir || typeof projectDir !== "string") {
212
+ return fail("projectDir is required and must be a non-empty string");
213
+ }
214
+ const stateResult = readStateFile(projectDir);
215
+ if (!stateResult.success) {
216
+ return fail(stateResult.error);
217
+ }
218
+ return ok(extractCoverageState(stateResult.data));
219
+ }
220
+ function getCoverageTrend(projectDir) {
221
+ if (!projectDir || typeof projectDir !== "string") {
222
+ return fail("projectDir is required and must be a non-empty string");
223
+ }
224
+ const stateResult = readCoverageState(projectDir);
225
+ if (!stateResult.success) {
226
+ return fail(stateResult.error);
227
+ }
228
+ const { history } = stateResult.data.static;
229
+ if (history.length === 0) {
230
+ return ok({
231
+ current: 0,
232
+ previous: null,
233
+ delta: null,
234
+ currentTimestamp: "",
235
+ previousTimestamp: null
236
+ });
237
+ }
238
+ const latest = history[history.length - 1];
239
+ if (history.length === 1) {
240
+ return ok({
241
+ current: latest.coveragePercent,
242
+ previous: null,
243
+ delta: null,
244
+ currentTimestamp: latest.timestamp,
245
+ previousTimestamp: null
246
+ });
247
+ }
248
+ const prev = history[history.length - 2];
249
+ return ok({
250
+ current: latest.coveragePercent,
251
+ previous: prev.coveragePercent,
252
+ delta: latest.coveragePercent - prev.coveragePercent,
253
+ currentTimestamp: latest.timestamp,
254
+ previousTimestamp: prev.timestamp
255
+ });
256
+ }
257
+ function checkCoverageTarget(projectDir, target) {
258
+ if (!projectDir || typeof projectDir !== "string") {
259
+ return fail("projectDir is required and must be a non-empty string");
260
+ }
261
+ const stateResult = readCoverageState(projectDir);
262
+ if (!stateResult.success) {
263
+ return fail(stateResult.error);
264
+ }
265
+ const effectiveTarget = target ?? stateResult.data.targets.staticTarget;
266
+ const current = stateResult.data.static.coveragePercent;
267
+ const met = current >= effectiveTarget;
268
+ const gap = met ? 0 : effectiveTarget - current;
269
+ return ok({ met, current, target: effectiveTarget, gap });
270
+ }
271
+ function extractCoverageState(state) {
272
+ const obs = state.observability;
273
+ if (!obs) {
274
+ return defaultCoverageState();
275
+ }
276
+ const staticSection = obs.static;
277
+ const targets = obs.targets;
278
+ return {
279
+ static: {
280
+ coveragePercent: typeof staticSection?.coveragePercent === "number" ? staticSection.coveragePercent : 0,
281
+ lastScanTimestamp: typeof staticSection?.lastScanTimestamp === "string" ? staticSection.lastScanTimestamp : "",
282
+ history: Array.isArray(staticSection?.history) ? staticSection.history.filter(
283
+ (entry) => typeof entry === "object" && entry !== null && typeof entry.coveragePercent === "number" && typeof entry.timestamp === "string"
284
+ ) : []
285
+ },
286
+ targets: {
287
+ staticTarget: typeof targets?.staticTarget === "number" ? targets.staticTarget : DEFAULT_STATIC_TARGET
288
+ }
289
+ };
290
+ }
291
+
292
+ // src/modules/observability/runtime-coverage.ts
293
+ import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, renameSync as renameSync2, existsSync as existsSync2 } from "fs";
294
+ import { join as join3 } from "path";
295
+ var STATE_FILE2 = "sprint-state.json";
296
+ var TMP_FILE2 = ".sprint-state.json.tmp";
297
+ function computeRuntimeCoverage(gapResults) {
298
+ const entries = gapResults.entries.map((entry) => ({
299
+ acId: entry.acId,
300
+ logEventsDetected: !entry.hasGap,
301
+ logEventCount: entry.hasGap ? 0 : 1,
302
+ // Binary: gap means 0, no gap means at least 1
303
+ gapNote: entry.gapNote
304
+ }));
305
+ const totalACs = gapResults.totalACs;
306
+ const acsWithLogs = gapResults.coveredCount;
307
+ const coveragePercent = totalACs === 0 ? 0 : acsWithLogs / totalACs * 100;
308
+ return { entries, totalACs, acsWithLogs, coveragePercent };
309
+ }
310
+ function saveRuntimeCoverage(projectDir, result) {
311
+ if (!projectDir || typeof projectDir !== "string") {
312
+ return fail("projectDir is required and must be a non-empty string");
313
+ }
314
+ const fp = join3(projectDir, STATE_FILE2);
315
+ let state = {};
316
+ if (existsSync2(fp)) {
317
+ try {
318
+ const raw = readFileSync2(fp, "utf-8");
319
+ state = JSON.parse(raw);
320
+ } catch (err) {
321
+ const msg = err instanceof Error ? err.message : String(err);
322
+ return fail(`Failed to read ${STATE_FILE2}: ${msg}`);
323
+ }
324
+ }
325
+ const existingObs = state.observability ?? {};
326
+ const runtimeState = {
327
+ coveragePercent: result.coveragePercent,
328
+ lastValidationTimestamp: (/* @__PURE__ */ new Date()).toISOString(),
329
+ modulesWithTelemetry: result.acsWithLogs,
330
+ totalModules: result.totalACs,
331
+ telemetryDetected: result.acsWithLogs > 0
332
+ };
333
+ const existingTargets = existingObs.targets ?? {};
334
+ const targets = {
335
+ ...existingTargets,
336
+ runtimeTarget: typeof existingTargets.runtimeTarget === "number" ? existingTargets.runtimeTarget : 60
337
+ };
338
+ const updatedState = {
339
+ ...state,
340
+ observability: {
341
+ ...existingObs,
342
+ runtime: runtimeState,
343
+ targets
344
+ }
345
+ };
346
+ try {
347
+ const data = JSON.stringify(updatedState, null, 2) + "\n";
348
+ const tmp = join3(projectDir, TMP_FILE2);
349
+ const finalPath = join3(projectDir, STATE_FILE2);
350
+ writeFileSync2(tmp, data, "utf-8");
351
+ renameSync2(tmp, finalPath);
352
+ return ok(void 0);
353
+ } catch (err) {
354
+ const msg = err instanceof Error ? err.message : String(err);
355
+ return fail(`Failed to write ${STATE_FILE2}: ${msg}`);
356
+ }
357
+ }
358
+ export {
359
+ analyze,
360
+ checkCoverageTarget,
361
+ computeRuntimeCoverage,
362
+ getCoverageTrend,
363
+ readCoverageState,
364
+ saveCoverageResult,
365
+ saveRuntimeCoverage
366
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeharness",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "type": "module",
5
5
  "description": "CLI for codeharness — makes autonomous coding agents produce software that actually works",
6
6
  "bin": {
@@ -29,7 +29,10 @@
29
29
  "test:coverage": "vitest run --coverage"
30
30
  },
31
31
  "dependencies": {
32
+ "@inkjs/ui": "^2.0.0",
32
33
  "commander": "^14.0.3",
34
+ "ink": "^6.8.0",
35
+ "react": "^19.2.4",
33
36
  "yaml": "^2.8.2"
34
37
  },
35
38
  "devDependencies": {
@@ -38,7 +41,9 @@
38
41
  "@opentelemetry/exporter-metrics-otlp-http": "^0.213.0",
39
42
  "@opentelemetry/exporter-trace-otlp-http": "^0.213.0",
40
43
  "@types/node": "^25.5.0",
44
+ "@types/react": "^19.2.14",
41
45
  "@vitest/coverage-v8": "^4.1.0",
46
+ "ink-testing-library": "^4.0.0",
42
47
  "tsup": "^8.5.1",
43
48
  "typescript": "^5.9.3",
44
49
  "vitest": "^4.1.0"