codeharness 0.22.0 → 0.22.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/dist/index.js +1009 -124
- package/dist/modules/observability/index.d.ts +59 -1
- package/dist/modules/observability/index.js +166 -1
- package/package.json +1 -1
- package/ralph/ralph.sh +6 -1
|
@@ -190,6 +190,50 @@ interface CoverageTargetResult {
|
|
|
190
190
|
/** Gap: target - current (0 if met) */
|
|
191
191
|
readonly gap: number;
|
|
192
192
|
}
|
|
193
|
+
/** Configuration for standalone runtime validation (audit mode) */
|
|
194
|
+
interface RuntimeValidationConfig {
|
|
195
|
+
/** Test command to run (default: 'npm test') */
|
|
196
|
+
readonly testCommand: string;
|
|
197
|
+
/** OTLP endpoint for telemetry export */
|
|
198
|
+
readonly otlpEndpoint: string;
|
|
199
|
+
/** Query endpoint for the observability backend (VictoriaLogs) */
|
|
200
|
+
readonly queryEndpoint: string;
|
|
201
|
+
/** Timeout for the entire validation process in milliseconds (default: 120000) */
|
|
202
|
+
readonly timeoutMs: number;
|
|
203
|
+
}
|
|
204
|
+
/** A single module's telemetry status from runtime validation */
|
|
205
|
+
interface ModuleTelemetryEntry {
|
|
206
|
+
/** Module name (derived from source directory) */
|
|
207
|
+
readonly moduleName: string;
|
|
208
|
+
/** Whether telemetry was detected for this module */
|
|
209
|
+
readonly telemetryDetected: boolean;
|
|
210
|
+
/** Number of telemetry events emitted by this module */
|
|
211
|
+
readonly eventCount: number;
|
|
212
|
+
}
|
|
213
|
+
/** Result of a standalone runtime validation run */
|
|
214
|
+
interface RuntimeValidationResult {
|
|
215
|
+
/** Per-module telemetry entries */
|
|
216
|
+
readonly entries: readonly ModuleTelemetryEntry[];
|
|
217
|
+
/** Total number of modules in the project */
|
|
218
|
+
readonly totalModules: number;
|
|
219
|
+
/** Number of modules that emitted telemetry */
|
|
220
|
+
readonly modulesWithTelemetry: number;
|
|
221
|
+
/** Runtime coverage percentage: modulesWithTelemetry / totalModules * 100 */
|
|
222
|
+
readonly coveragePercent: number;
|
|
223
|
+
/** Whether validation was skipped (e.g., backend unreachable) */
|
|
224
|
+
readonly skipped: boolean;
|
|
225
|
+
/** Reason validation was skipped, if applicable */
|
|
226
|
+
readonly skipReason?: string;
|
|
227
|
+
}
|
|
228
|
+
/** A telemetry event returned from the observability backend query */
|
|
229
|
+
interface TelemetryEvent {
|
|
230
|
+
/** ISO 8601 timestamp of the event */
|
|
231
|
+
readonly timestamp: string;
|
|
232
|
+
/** Log message or event description */
|
|
233
|
+
readonly message: string;
|
|
234
|
+
/** Source file or service attribute */
|
|
235
|
+
readonly source: string;
|
|
236
|
+
}
|
|
193
237
|
|
|
194
238
|
/**
|
|
195
239
|
* Discriminated union Result type for consistent error handling.
|
|
@@ -306,4 +350,18 @@ declare function checkObservabilityCoverageGate(projectDir: string, overrides?:
|
|
|
306
350
|
runtimeTarget?: number;
|
|
307
351
|
}): Result<ObservabilityCoverageGateResult>;
|
|
308
352
|
|
|
309
|
-
|
|
353
|
+
/**
|
|
354
|
+
* Standalone runtime validator — validates telemetry outside Docker verification.
|
|
355
|
+
* Implements Story 2.3: run tests with OTLP, query backend, report module coverage.
|
|
356
|
+
*/
|
|
357
|
+
|
|
358
|
+
/** Run tests with OTLP enabled, query backend for events, compute module coverage. */
|
|
359
|
+
declare function validateRuntime(projectDir: string, config?: Partial<RuntimeValidationConfig>): Promise<Result<RuntimeValidationResult>>;
|
|
360
|
+
/** Check if the observability backend is reachable (2xx within 3s). */
|
|
361
|
+
declare function checkBackendHealth(queryEndpoint: string): Promise<boolean>;
|
|
362
|
+
/** Query VictoriaLogs for telemetry events within a time window. */
|
|
363
|
+
declare function queryTelemetryEvents(queryEndpoint: string, startTime: string, endTime: string): Promise<Result<TelemetryEvent[]>>;
|
|
364
|
+
/** Map telemetry events to project modules by matching source paths. */
|
|
365
|
+
declare function mapEventsToModules(events: TelemetryEvent[], projectDir: string, modules?: string[]): ModuleTelemetryEntry[];
|
|
366
|
+
|
|
367
|
+
export { type AnalyzerConfig, type AnalyzerResult, type AnalyzerSummary, type CoverageHistoryEntry, type CoverageTargetResult, type CoverageTargets, type CoverageTrend, type GapSeverity, type ModuleTelemetryEntry, type ObservabilityCoverageGateResult, type ObservabilityCoverageState, type ObservabilityGap, type RuntimeCoverageEntry, type RuntimeCoverageResult, type RuntimeCoverageState, type RuntimeValidationConfig, type RuntimeValidationResult, type StaticCoverageState, type TelemetryEvent, analyze, checkBackendHealth, checkCoverageTarget, checkObservabilityCoverageGate, computeRuntimeCoverage, getCoverageTrend, mapEventsToModules, queryTelemetryEvents, readCoverageState, saveCoverageResult, saveRuntimeCoverage, validateRuntime };
|
|
@@ -422,13 +422,178 @@ function checkObservabilityCoverageGate(projectDir, overrides) {
|
|
|
422
422
|
const gapSummary = state.static.gaps ? [...state.static.gaps] : [];
|
|
423
423
|
return ok({ passed, staticResult, runtimeResult, gapSummary });
|
|
424
424
|
}
|
|
425
|
+
|
|
426
|
+
// src/modules/observability/runtime-validator.ts
|
|
427
|
+
import { execSync } from "child_process";
|
|
428
|
+
import { readdirSync, statSync } from "fs";
|
|
429
|
+
import { join as join4 } from "path";
|
|
430
|
+
var DEFAULT_CONFIG = {
|
|
431
|
+
testCommand: "npm test",
|
|
432
|
+
otlpEndpoint: "http://localhost:4318",
|
|
433
|
+
queryEndpoint: "http://localhost:9428",
|
|
434
|
+
timeoutMs: 12e4
|
|
435
|
+
};
|
|
436
|
+
var HEALTH_TIMEOUT_MS = 3e3;
|
|
437
|
+
var QUERY_TIMEOUT_MS = 3e4;
|
|
438
|
+
async function validateRuntime(projectDir, config) {
|
|
439
|
+
if (!projectDir || typeof projectDir !== "string") {
|
|
440
|
+
return fail("projectDir is required and must be a non-empty string");
|
|
441
|
+
}
|
|
442
|
+
const cfg = { ...DEFAULT_CONFIG, ...config };
|
|
443
|
+
if (/[;&|`$(){}!#]/.test(cfg.testCommand)) {
|
|
444
|
+
return fail(`testCommand contains disallowed shell metacharacters: ${cfg.testCommand}`);
|
|
445
|
+
}
|
|
446
|
+
const healthy = await checkBackendHealth(cfg.queryEndpoint);
|
|
447
|
+
if (!healthy) {
|
|
448
|
+
const modules2 = discoverModules(projectDir);
|
|
449
|
+
const entries2 = modules2.map((m) => ({
|
|
450
|
+
moduleName: m,
|
|
451
|
+
telemetryDetected: false,
|
|
452
|
+
eventCount: 0
|
|
453
|
+
}));
|
|
454
|
+
return ok({
|
|
455
|
+
entries: entries2,
|
|
456
|
+
totalModules: modules2.length,
|
|
457
|
+
modulesWithTelemetry: 0,
|
|
458
|
+
coveragePercent: 0,
|
|
459
|
+
skipped: true,
|
|
460
|
+
skipReason: "runtime validation skipped -- observability stack not available"
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
const startTime = (/* @__PURE__ */ new Date()).toISOString();
|
|
464
|
+
try {
|
|
465
|
+
execSync(cfg.testCommand, {
|
|
466
|
+
cwd: projectDir,
|
|
467
|
+
timeout: cfg.timeoutMs,
|
|
468
|
+
env: {
|
|
469
|
+
...process.env,
|
|
470
|
+
OTEL_EXPORTER_OTLP_ENDPOINT: cfg.otlpEndpoint
|
|
471
|
+
},
|
|
472
|
+
stdio: "pipe"
|
|
473
|
+
});
|
|
474
|
+
} catch (err) {
|
|
475
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
476
|
+
return fail(`Test command failed: ${msg}`);
|
|
477
|
+
}
|
|
478
|
+
const endTime = (/* @__PURE__ */ new Date()).toISOString();
|
|
479
|
+
const eventsResult = await queryTelemetryEvents(cfg.queryEndpoint, startTime, endTime);
|
|
480
|
+
if (!eventsResult.success) {
|
|
481
|
+
return fail(eventsResult.error);
|
|
482
|
+
}
|
|
483
|
+
const modules = discoverModules(projectDir);
|
|
484
|
+
const entries = mapEventsToModules(eventsResult.data, projectDir, modules);
|
|
485
|
+
const modulesWithTelemetry = entries.filter((e) => e.telemetryDetected).length;
|
|
486
|
+
const totalModules = entries.length;
|
|
487
|
+
const coveragePercent = totalModules === 0 ? 0 : modulesWithTelemetry / totalModules * 100;
|
|
488
|
+
return ok({
|
|
489
|
+
entries,
|
|
490
|
+
totalModules,
|
|
491
|
+
modulesWithTelemetry,
|
|
492
|
+
coveragePercent,
|
|
493
|
+
skipped: false
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
async function checkBackendHealth(queryEndpoint) {
|
|
497
|
+
try {
|
|
498
|
+
const response = await fetch(`${queryEndpoint}/health`, {
|
|
499
|
+
signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS)
|
|
500
|
+
});
|
|
501
|
+
return response.ok;
|
|
502
|
+
} catch {
|
|
503
|
+
return false;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
async function queryTelemetryEvents(queryEndpoint, startTime, endTime) {
|
|
507
|
+
let url;
|
|
508
|
+
try {
|
|
509
|
+
url = new URL("/select/logsql/query", queryEndpoint);
|
|
510
|
+
} catch {
|
|
511
|
+
return fail(`Invalid queryEndpoint URL: ${queryEndpoint}`);
|
|
512
|
+
}
|
|
513
|
+
url.searchParams.set("query", "*");
|
|
514
|
+
url.searchParams.set("start", startTime);
|
|
515
|
+
url.searchParams.set("end", endTime);
|
|
516
|
+
url.searchParams.set("limit", "1000");
|
|
517
|
+
try {
|
|
518
|
+
const response = await fetch(url.toString(), {
|
|
519
|
+
signal: AbortSignal.timeout(QUERY_TIMEOUT_MS)
|
|
520
|
+
});
|
|
521
|
+
if (!response.ok) {
|
|
522
|
+
return fail(`VictoriaLogs returned ${response.status}`);
|
|
523
|
+
}
|
|
524
|
+
const text = await response.text();
|
|
525
|
+
const events = parseLogEvents(text);
|
|
526
|
+
return ok(events);
|
|
527
|
+
} catch (err) {
|
|
528
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
529
|
+
return fail(`Failed to query telemetry events: ${msg}`);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
function mapEventsToModules(events, projectDir, modules) {
|
|
533
|
+
void projectDir;
|
|
534
|
+
const moduleList = modules ?? [];
|
|
535
|
+
const moduleCounts = /* @__PURE__ */ new Map();
|
|
536
|
+
for (const mod of moduleList) {
|
|
537
|
+
moduleCounts.set(mod, 0);
|
|
538
|
+
}
|
|
539
|
+
for (const event of events) {
|
|
540
|
+
for (const mod of moduleList) {
|
|
541
|
+
if (event.source.includes(mod) || event.message.includes(mod)) {
|
|
542
|
+
moduleCounts.set(mod, (moduleCounts.get(mod) ?? 0) + 1);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return moduleList.map((mod) => {
|
|
547
|
+
const count = moduleCounts.get(mod) ?? 0;
|
|
548
|
+
return {
|
|
549
|
+
moduleName: mod,
|
|
550
|
+
telemetryDetected: count > 0,
|
|
551
|
+
eventCount: count
|
|
552
|
+
};
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
function discoverModules(projectDir) {
|
|
556
|
+
const srcDir = join4(projectDir, "src");
|
|
557
|
+
try {
|
|
558
|
+
return readdirSync(srcDir).filter((name) => {
|
|
559
|
+
try {
|
|
560
|
+
return statSync(join4(srcDir, name)).isDirectory();
|
|
561
|
+
} catch {
|
|
562
|
+
return false;
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
} catch {
|
|
566
|
+
return [];
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
function parseLogEvents(text) {
|
|
570
|
+
if (!text.trim()) return [];
|
|
571
|
+
const lines = text.trim().split("\n");
|
|
572
|
+
const events = [];
|
|
573
|
+
for (const line of lines) {
|
|
574
|
+
try {
|
|
575
|
+
const raw = JSON.parse(line);
|
|
576
|
+
events.push({
|
|
577
|
+
timestamp: String(raw._time ?? raw.timestamp ?? ""),
|
|
578
|
+
message: String(raw._msg ?? raw.message ?? ""),
|
|
579
|
+
source: String(raw.source ?? raw._source ?? raw.service ?? "")
|
|
580
|
+
});
|
|
581
|
+
} catch {
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
return events;
|
|
585
|
+
}
|
|
425
586
|
export {
|
|
426
587
|
analyze,
|
|
588
|
+
checkBackendHealth,
|
|
427
589
|
checkCoverageTarget,
|
|
428
590
|
checkObservabilityCoverageGate,
|
|
429
591
|
computeRuntimeCoverage,
|
|
430
592
|
getCoverageTrend,
|
|
593
|
+
mapEventsToModules,
|
|
594
|
+
queryTelemetryEvents,
|
|
431
595
|
readCoverageState,
|
|
432
596
|
saveCoverageResult,
|
|
433
|
-
saveRuntimeCoverage
|
|
597
|
+
saveRuntimeCoverage,
|
|
598
|
+
validateRuntime
|
|
434
599
|
};
|
package/package.json
CHANGED
package/ralph/ralph.sh
CHANGED
|
@@ -782,7 +782,12 @@ execute_iteration() {
|
|
|
782
782
|
fi
|
|
783
783
|
|
|
784
784
|
local has_errors="false"
|
|
785
|
-
|
|
785
|
+
# Only check non-JSON lines for errors. Stream-json output is NDJSON
|
|
786
|
+
# (one JSON object per line), so any line starting with '{' is Claude
|
|
787
|
+
# content — which naturally contains words like "error" and "Exception"
|
|
788
|
+
# in code reviews, test output, and discussion. Grepping those produces
|
|
789
|
+
# false positives that trip the circuit breaker.
|
|
790
|
+
if grep -v '^[[:space:]]*{' "$output_file" 2>/dev/null | \
|
|
786
791
|
grep -qE '(^Error:|^ERROR:|^error:|\]: error|Error occurred|failed with error|[Ee]xception|Fatal|FATAL)'; then
|
|
787
792
|
has_errors="true"
|
|
788
793
|
log_status "WARN" "Errors detected in output"
|