karajan-code 3.0.0 → 3.2.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 +14 -6
- package/docs/README.es.md +1 -1
- package/package.json +8 -2
- package/packages/hu-board/package.json +3 -1
- package/packages/hu-board/public/app.js +81 -4025
- package/packages/hu-board/public/index.html +21 -0
- package/packages/hu-board/public/styles.css +32 -0
- package/packages/hu-board/public/utils/api.js +139 -0
- package/packages/hu-board/public/utils/board-view.js +462 -0
- package/packages/hu-board/public/utils/command-launcher.js +213 -0
- package/packages/hu-board/public/utils/config-editor.js +181 -0
- package/packages/hu-board/public/utils/dashboard-view.js +103 -0
- package/packages/hu-board/public/utils/formatters.js +273 -0
- package/packages/hu-board/public/utils/graph-view.js +216 -0
- package/packages/hu-board/public/utils/hu-actions.js +224 -0
- package/packages/hu-board/public/utils/init-listeners.js +180 -0
- package/packages/hu-board/public/utils/log-panel.js +222 -0
- package/packages/hu-board/public/utils/modals.js +290 -0
- package/packages/hu-board/public/utils/plan-rollup-view.js +53 -0
- package/packages/hu-board/public/utils/preflight-view.js +89 -0
- package/packages/hu-board/public/utils/project-actions.js +143 -0
- package/packages/hu-board/public/utils/project-picker-view.js +139 -0
- package/packages/hu-board/public/utils/run-launcher.js +169 -0
- package/packages/hu-board/public/utils/server-push.js +252 -0
- package/packages/hu-board/public/utils/sessions-view.js +199 -0
- package/packages/hu-board/public/utils/story-detail-view.js +324 -0
- package/packages/hu-board/public/utils/story-edit-form.js +271 -0
- package/packages/hu-board/public/wiki.html +32 -0
- package/packages/hu-board/public/wiki.js +53 -0
- package/packages/hu-board/src/config-yaml.js +14 -0
- package/packages/hu-board/src/db.js +93 -2
- package/packages/hu-board/src/format.js +79 -0
- package/packages/hu-board/src/plan-mutations.js +4 -4
- package/packages/hu-board/src/routes/api.js +43 -10
- package/packages/hu-board/src/routes/rag.js +1 -1
- package/packages/hu-board/src/routes/wiki.js +43 -0
- package/packages/hu-board/src/run-tracker.js +1 -1
- package/packages/hu-board/src/server.js +4 -2
- package/packages/hu-board/src/sync.js +1 -1
- package/scripts/audit-test-diet.mjs +151 -0
- package/scripts/git-hooks/post-merge +13 -2
- package/src/audit/ai-slop-findings.js +102 -0
- package/src/audit/deterministic-summary.js +18 -0
- package/src/audit/harness-scorecard.js +52 -3
- package/src/audit/harness-section.js +6 -2
- package/src/brain/standby-scheduler.js +10 -92
- package/src/brain/standby-store.js +12 -168
- package/src/checks/ai-trash.js +34 -0
- package/src/checks/qmd.js +41 -0
- package/src/checks/runner.js +10 -0
- package/src/checks/squeezr.js +36 -0
- package/src/cli/register-meta.js +36 -2
- package/src/cli/register-pipeline.js +3 -0
- package/src/cli/register-plan.js +10 -1
- package/src/commands/audit.js +6 -4
- package/src/commands/board.js +34 -0
- package/src/commands/clean.js +62 -0
- package/src/commands/doctor.js +6 -0
- package/src/commands/init.js +73 -8
- package/src/commands/plan/generate.js +9 -3
- package/src/commands/qmd.js +86 -0
- package/src/commands/resume.js +6 -45
- package/src/commands/run.js +6 -50
- package/src/commands/sync.js +30 -2
- package/src/config/defaults.js +5 -0
- package/src/config/schema.js +2 -0
- package/src/git/hu-snapshot.js +10 -105
- package/src/orchestrator/config-init.js +1 -0
- package/src/orchestrator/drivers/iteration-phases/quality-gates.js +23 -0
- package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +16 -0
- package/src/orchestrator/drivers/run-hu-batch.js +5 -1
- package/src/orchestrator/hu-sub-pipeline.js +52 -8
- package/src/orchestrator/stages/coder-stage.js +8 -2
- package/src/orchestrator/stages/tdd-discipline-stage.js +56 -0
- package/src/orchestrator/stages/tool-judge-stage.js +53 -0
- package/src/plan/plan-hu-ops.js +16 -338
- package/src/plan/plan-id.js +4 -45
- package/src/plan/plan-validation.js +4 -82
- package/src/prompts/tool-correctness-judge.js +31 -0
- package/src/rag/vec-store.js +17 -253
- package/src/review/git-stash-helper.js +104 -0
- package/src/review/tdd-discipline.js +90 -0
- package/src/roles/audit-role.js +11 -1
- package/src/roles/tool-correctness-judge-role.js +78 -0
- package/src/session/mutators.js +4 -0
- package/src/sync/apply-canvas-patch.js +55 -0
- package/src/utils/ai-trash.js +48 -0
- package/src/utils/atomic-write.js +6 -43
- package/src/utils/cli-ask-question.js +76 -0
- package/src/utils/doc-links.js +2 -0
- package/src/utils/os-detect.js +10 -0
- package/src/utils/paths.js +14 -111
- package/src/utils/port-check.js +6 -45
- package/src/utils/process.js +4 -188
- package/src/utils/qmd-collection.js +72 -0
- package/src/utils/qmd-detect.js +18 -0
- package/src/utils/qmd-install.js +37 -0
- package/src/utils/repo-cleaner.js +79 -0
- package/src/utils/run-registry.js +9 -116
- package/src/utils/shared-paths.js +12 -41
- package/src/utils/squeezr-detect.js +18 -0
- package/src/utils/squeezr-install.js +37 -0
- package/src/utils/tool-call-extractor.js +56 -0
- package/src/utils/vector-store-orphans.js +89 -0
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
listPlanIdsForProject,
|
|
23
23
|
updateStoryStatus,
|
|
24
24
|
setProjectIsTest,
|
|
25
|
+
getProjectCost,
|
|
25
26
|
} from '../db.js';
|
|
26
27
|
import { fullScan } from '../sync.js';
|
|
27
28
|
import { setHuStatus, setHuFields, markPlanReady, runPlan, renameProject, revertHuFromSnapshot } from '../plan-mutations.js';
|
|
@@ -285,6 +286,20 @@ router.get('/stories/:id', (req, res) => {
|
|
|
285
286
|
}
|
|
286
287
|
});
|
|
287
288
|
|
|
289
|
+
/**
|
|
290
|
+
* GET /api/projects/:id/cost - Aggregated $ cost for a project (KJC-TSK-0516).
|
|
291
|
+
* Sums stories.cost_usd, groups by plan_id, returns 404 if project missing.
|
|
292
|
+
*/
|
|
293
|
+
router.get('/projects/:id/cost', (req, res) => {
|
|
294
|
+
try {
|
|
295
|
+
const cost = getProjectCost(req.params.id);
|
|
296
|
+
if (!cost) return res.status(404).json({ error: 'Project not found' });
|
|
297
|
+
res.json(cost);
|
|
298
|
+
} catch (err) {
|
|
299
|
+
res.status(500).json({ error: err.message });
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
|
|
288
303
|
/**
|
|
289
304
|
* GET /api/projects/:id/sessions - Sessions for a project.
|
|
290
305
|
*/
|
|
@@ -545,15 +560,27 @@ router.post('/tombstones/:type/:id/restore', (req, res) => {
|
|
|
545
560
|
* rewritten before we ack, then re-synced into SQLite, so a reload
|
|
546
561
|
* renders the committed state.
|
|
547
562
|
*/
|
|
548
|
-
//
|
|
549
|
-
//
|
|
550
|
-
//
|
|
551
|
-
//
|
|
552
|
-
//
|
|
553
|
-
//
|
|
554
|
-
//
|
|
555
|
-
//
|
|
556
|
-
const ALLOWED_STORY_STATUSES = new Set(['pending', '
|
|
563
|
+
// KJC-TSK-0394 PR6 (AC 6): el PATCH endpoint sólo acepta el modelo
|
|
564
|
+
// canónico {pending, running, done}. Status legacy (`certified`,
|
|
565
|
+
// `coding`, `reviewing`, `failed`, `blocked`, `needs_context`) siguen
|
|
566
|
+
// vivos en plans existentes y los traduce la UI con canonicalStatus(),
|
|
567
|
+
// pero NO pueden ser seteados por escrituras nuevas — devolvemos 400
|
|
568
|
+
// con `suggestion` apuntando al mapping correcto. `running` queda fuera
|
|
569
|
+
// del dropdown del modal (lifecycle del orquestador), pero el endpoint
|
|
570
|
+
// lo acepta por si un programa externo lo necesita.
|
|
571
|
+
const ALLOWED_STORY_STATUSES = new Set(['pending', 'running', 'done']);
|
|
572
|
+
// Mapping canónico para la sugerencia del 400 cuando el caller manda
|
|
573
|
+
// un valor legacy. Mantenemos la regla viva en src/plan/plan-schema.js
|
|
574
|
+
// (mapLegacyStatusToResult) — esta tabla es sólo el subset accionable
|
|
575
|
+
// desde el endpoint (status + result).
|
|
576
|
+
const LEGACY_STATUS_SUGGESTION = {
|
|
577
|
+
certified: { status: 'done', result: 'pass' },
|
|
578
|
+
failed: { status: 'pending', result: 'fail' },
|
|
579
|
+
coding: { status: 'running', result: null },
|
|
580
|
+
reviewing: { status: 'running', result: null },
|
|
581
|
+
blocked: { status: 'pending', result: null },
|
|
582
|
+
needs_context: { status: 'pending', result: null },
|
|
583
|
+
};
|
|
557
584
|
// KJC-TSK-0406: coder_model y reviewer_model son editables desde el
|
|
558
585
|
// modal del board. Independientes — el usuario puede subir el reviewer
|
|
559
586
|
// y bajar el coder sin afectar al resto del plan.
|
|
@@ -579,8 +606,14 @@ router.patch('/stories/:id', (req, res) => {
|
|
|
579
606
|
});
|
|
580
607
|
}
|
|
581
608
|
if (hasStatus && !ALLOWED_STORY_STATUSES.has(body.status)) {
|
|
609
|
+
// KJC-TSK-0394 PR6 (AC 6): si el caller manda un status legacy,
|
|
610
|
+
// devolvemos un 400 con `suggestion` apuntando al mapping canónico.
|
|
611
|
+
// Una herramienta externa puede leer el body, aplicar el mapeo y
|
|
612
|
+
// re-intentar — sin tener que descubrir la tabla por ensayo y error.
|
|
613
|
+
const suggestion = LEGACY_STATUS_SUGGESTION[body.status] || null;
|
|
582
614
|
return res.status(400).json({
|
|
583
615
|
error: `status must be one of: ${[...ALLOWED_STORY_STATUSES].join(', ')}`,
|
|
616
|
+
suggestion,
|
|
584
617
|
});
|
|
585
618
|
}
|
|
586
619
|
|
|
@@ -1307,7 +1340,7 @@ router.post('/rag/query', async (req, res) => {
|
|
|
1307
1340
|
return res.status(400).json({ error: "'text' is required (non-empty string)" });
|
|
1308
1341
|
}
|
|
1309
1342
|
try {
|
|
1310
|
-
const { openVecStore, countChunks } = await import('
|
|
1343
|
+
const { openVecStore, countChunks } = await import('@karajan/core/vec-store');
|
|
1311
1344
|
const { makeEmbedder } = await import('../../../../src/rag/embedders/factory.js');
|
|
1312
1345
|
const { query } = await import('../../../../src/rag/retriever.js');
|
|
1313
1346
|
const db = openVecStore({ dim: 768 });
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { Router } from "express";
|
|
6
6
|
import { existsSync, statSync } from "node:fs";
|
|
7
7
|
import Database from "better-sqlite3";
|
|
8
|
-
import { dbPath } from "
|
|
8
|
+
import { dbPath } from "@karajan/core/vec-store";
|
|
9
9
|
import { readConfig } from "../config-yaml.js";
|
|
10
10
|
|
|
11
11
|
const router = Router();
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// KJC-TSK-0508 — HU Board Wiki panel route.
|
|
2
|
+
// POST /api/wiki/query → spawns `qmd query --format json` against the local
|
|
3
|
+
// per-project QMD wiki (registered by `kj init` via KJC-TSK-0506). Returns
|
|
4
|
+
// 503 with installable:true so the front renders an "Install QMD" CTA
|
|
5
|
+
// instead of a generic error when the binary is missing.
|
|
6
|
+
import { Router } from "express";
|
|
7
|
+
import { runCommand } from "@karajan/core/process";
|
|
8
|
+
|
|
9
|
+
const router = Router();
|
|
10
|
+
const QMD_NOT_INSTALLED = /command not found|ENOENT|not recognized as/i;
|
|
11
|
+
|
|
12
|
+
router.post("/query", async (req, res) => {
|
|
13
|
+
const raw = req.body?.query;
|
|
14
|
+
const query = typeof raw === "string" ? raw.trim() : "";
|
|
15
|
+
if (!query) return res.status(400).json({ ok: false, error: "query is required" });
|
|
16
|
+
const limit = Math.min(Math.max(parseInt(req.body?.limit, 10) || 10, 1), 50);
|
|
17
|
+
const collection = req.body?.collection ? String(req.body.collection).trim() : null;
|
|
18
|
+
const fast = req.body?.fast === true;
|
|
19
|
+
|
|
20
|
+
const args = ["query", query, "--format", "json", "-n", String(limit)];
|
|
21
|
+
if (collection) args.push("-c", collection);
|
|
22
|
+
if (fast) args.push("--no-rerank");
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const out = await runCommand("qmd", args, { timeout: 120_000 });
|
|
26
|
+
if (out.exitCode === 127 || QMD_NOT_INSTALLED.test(out.stderr || "")) {
|
|
27
|
+
return res.status(503).json({ ok: false, error: "qmd not installed", installable: true });
|
|
28
|
+
}
|
|
29
|
+
if (out.exitCode !== 0) {
|
|
30
|
+
const msg = (out.stderr || out.stdout || `qmd exit ${out.exitCode}`).trim();
|
|
31
|
+
return res.status(500).json({ ok: false, error: msg });
|
|
32
|
+
}
|
|
33
|
+
let results;
|
|
34
|
+
try { results = JSON.parse(out.stdout || "[]"); }
|
|
35
|
+
catch { return res.status(500).json({ ok: false, error: "qmd output is not valid JSON" }); }
|
|
36
|
+
res.json({ ok: true, results, count: Array.isArray(results) ? results.length : 0 });
|
|
37
|
+
} catch (err) {
|
|
38
|
+
if (err?.code === "ENOENT") return res.status(503).json({ ok: false, error: "qmd not installed", installable: true });
|
|
39
|
+
res.status(500).json({ ok: false, error: err?.message || String(err) });
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export default router;
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* se borran como cleanup (responsabilidad de run-registry).
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { listActiveRuns as fsList } from "
|
|
19
|
+
import { listActiveRuns as fsList } from "@karajan/core/run-registry";
|
|
20
20
|
|
|
21
21
|
const runs = new Map();
|
|
22
22
|
let reaperTimer = null;
|
|
@@ -10,13 +10,14 @@ import { fullScan, startWatcher } from './sync.js';
|
|
|
10
10
|
import apiRoutes from './routes/api.js';
|
|
11
11
|
import pipelineRoutes from './routes/pipeline.js';
|
|
12
12
|
import ragRoutes from './routes/rag.js';
|
|
13
|
+
import wikiRoutes from './routes/wiki.js';
|
|
13
14
|
import { authMiddleware } from './auth.js';
|
|
14
15
|
import { getOrCreateToken, getTokenPath } from './token-store.js';
|
|
15
16
|
import { reapZombieSessions } from './zombie-reaper.js';
|
|
16
17
|
import { reapZombieHus } from './hu-zombie-reaper.js';
|
|
17
18
|
import { setHuStatus as setHuStatusPlanMutation, setHuFailResult as setHuFailResultPlanMutation } from './plan-mutations.js';
|
|
18
19
|
import { cleanupEphemeralProjects } from './ephemeral-cleaner.js';
|
|
19
|
-
import { findAvailablePort as findAvailablePortBase } from '
|
|
20
|
+
import { findAvailablePort as findAvailablePortBase } from '@karajan/core/port-check';
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* Path to the PID file the CLI's `kj board start` / `kj board stop`
|
|
@@ -237,7 +238,7 @@ async function main() {
|
|
|
237
238
|
// - cooldownUntil > now → re-programa setTimeout que dispara en cooldown
|
|
238
239
|
// Idempotente (lockfile per session). Cero polling.
|
|
239
240
|
try {
|
|
240
|
-
const { reconcileAll } = await import('
|
|
241
|
+
const { reconcileAll } = await import('@karajan/core/standby-scheduler');
|
|
241
242
|
const { spawn } = await import('node:child_process');
|
|
242
243
|
const { join, dirname } = await import('node:path');
|
|
243
244
|
const { fileURLToPath } = await import('node:url');
|
|
@@ -300,6 +301,7 @@ async function main() {
|
|
|
300
301
|
app.use('/api', buildRateLimiter(), authMiddleware(), apiRoutes);
|
|
301
302
|
app.use('/api/pipeline', authMiddleware(), pipelineRoutes);
|
|
302
303
|
app.use('/api/rag', authMiddleware(), ragRoutes);
|
|
304
|
+
app.use('/api/wiki', authMiddleware(), wikiRoutes);
|
|
303
305
|
|
|
304
306
|
// SPA fallback: serve index.html for non-API, non-static routes
|
|
305
307
|
app.get('/{*splat}', (_req, res) => {
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
removeTombstone,
|
|
15
15
|
} from './db.js';
|
|
16
16
|
import { publish as publishEvent } from './event-bus.js';
|
|
17
|
-
import { getSharedPlansDir, SHARED_DIR_NAME, isSharedPath } from '
|
|
17
|
+
import { getSharedPlansDir, SHARED_DIR_NAME, isSharedPath } from '@karajan/core/shared-paths';
|
|
18
18
|
import { readConfig } from './config-yaml.js';
|
|
19
19
|
|
|
20
20
|
/**
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* scripts/audit-test-diet.mjs — semantic loss-of-meaning auditor (KJC-TSK-0345).
|
|
4
|
+
* Categories: empty-no-expect, skipped-pending, imports-orphan, deprecated-export,
|
|
5
|
+
* subsumed-candidate. Each finding carries confidence (high|low). Complements
|
|
6
|
+
* tests/_diet/audit.mjs (which classifies by LOC bucket only).
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
|
|
12
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const DEFAULT_ROOT = resolve(HERE, "..");
|
|
14
|
+
const SKIP_DIRS = new Set(["_diet", "fixtures", "node_modules"]);
|
|
15
|
+
|
|
16
|
+
export function walkTests(rootDir, testsSubdir = "tests") {
|
|
17
|
+
const out = [];
|
|
18
|
+
const base = join(rootDir, testsSubdir);
|
|
19
|
+
if (!existsSync(base)) return out;
|
|
20
|
+
const stack = [base];
|
|
21
|
+
while (stack.length) {
|
|
22
|
+
for (const e of readdirSync(stack.pop(), { withFileTypes: true })) {
|
|
23
|
+
const p = join(e.parentPath ?? base, e.name);
|
|
24
|
+
if (e.isDirectory() && !SKIP_DIRS.has(e.name)) stack.push(p);
|
|
25
|
+
else if (e.isFile() && /\.test\.(m?js|ts)$/.test(e.name)) out.push(p);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function parseImports(source) {
|
|
32
|
+
const imports = [];
|
|
33
|
+
const re = /^[ \t]*import\s+(?:([\w*\s{},]+?)\s+from\s+)?['"]([^'"]+)['"]/gm;
|
|
34
|
+
let m;
|
|
35
|
+
while ((m = re.exec(source)) !== null) {
|
|
36
|
+
const named = [];
|
|
37
|
+
const braced = (m[1] || "").match(/\{([^}]+)\}/);
|
|
38
|
+
if (braced) for (const part of braced[1].split(",")) {
|
|
39
|
+
const name = part.trim().split(/\s+as\s+/)[0].trim();
|
|
40
|
+
if (name) named.push(name);
|
|
41
|
+
}
|
|
42
|
+
imports.push({ specifier: m[2], named });
|
|
43
|
+
}
|
|
44
|
+
return imports;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function resolveSrcImport(testFile, specifier, rootDir) {
|
|
48
|
+
if (!specifier.startsWith(".") && !specifier.startsWith("#")) return null;
|
|
49
|
+
const base = specifier.startsWith("#") ? resolve(rootDir, specifier.replace(/^#/, "src/")) : resolve(dirname(testFile), specifier);
|
|
50
|
+
for (const c of [base, `${base}.js`, `${base}.mjs`, join(base, "index.js")]) {
|
|
51
|
+
if (existsSync(c) && statSync(c).isFile()) return c;
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function extractItNames(source) {
|
|
57
|
+
const names = new Set();
|
|
58
|
+
for (const m of source.matchAll(/\b(?:it|test)(?:\.(?:only|skip|todo))?\s*\(\s*['"`]([^'"`]+)['"`]/g)) names.add(m[1]);
|
|
59
|
+
return names;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function extractExports(source) {
|
|
63
|
+
const names = new Set();
|
|
64
|
+
for (const m of source.matchAll(/export\s+(?:async\s+)?(?:function|class|const|let|var)\s+([A-Za-z_$][\w$]*)/g)) names.add(m[1]);
|
|
65
|
+
for (const m of source.matchAll(/export\s*\{([^}]+)\}/g)) for (const part of m[1].split(",")) {
|
|
66
|
+
const name = part.trim().split(/\s+as\s+/).pop().trim();
|
|
67
|
+
if (name) names.add(name);
|
|
68
|
+
}
|
|
69
|
+
if (/export\s+default\b/.test(source)) names.add("default");
|
|
70
|
+
return names;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function auditFile(testFile, rootDir) {
|
|
74
|
+
const source = readFileSync(testFile, "utf-8");
|
|
75
|
+
const rel = relative(rootDir, testFile);
|
|
76
|
+
const findings = [];
|
|
77
|
+
const hasIt = /\b(?:it|test)\s*\(/.test(source);
|
|
78
|
+
if (hasIt && !/\bexpect\s*\(/.test(source)) findings.push({ category: "empty-no-expect", confidence: "high", detail: "it()/test() blocks with no expect()" });
|
|
79
|
+
if (/\b(?:it|test|describe)\.skip\s*\(|\bxit\s*\(|\bxtest\s*\(|\b(?:it|test)\.todo\s*\(/.test(source)) {
|
|
80
|
+
findings.push({ category: "skipped-pending", confidence: "high", detail: "uses .skip / xit / xtest / .todo" });
|
|
81
|
+
}
|
|
82
|
+
const srcImports = [];
|
|
83
|
+
for (const imp of parseImports(source)) {
|
|
84
|
+
if (!imp.specifier.startsWith(".") && !imp.specifier.startsWith("#")) continue;
|
|
85
|
+
const resolved = resolveSrcImport(testFile, imp.specifier, rootDir);
|
|
86
|
+
if (!resolved) { findings.push({ category: "imports-orphan", confidence: "high", detail: `cannot resolve '${imp.specifier}'` }); continue; }
|
|
87
|
+
if (imp.named.length) {
|
|
88
|
+
const missing = imp.named.filter((n) => !extractExports(readFileSync(resolved, "utf-8")).has(n));
|
|
89
|
+
if (missing.length) findings.push({ category: "deprecated-export", confidence: "low", detail: `${imp.specifier} no longer exports: ${missing.join(", ")}` });
|
|
90
|
+
}
|
|
91
|
+
srcImports.push({ resolved: relative(rootDir, resolved), named: imp.named });
|
|
92
|
+
}
|
|
93
|
+
return { path: rel, srcImports, findings, itNames: extractItNames(source) };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function findSubsumed(perFile) {
|
|
97
|
+
const bySrc = new Map();
|
|
98
|
+
for (const f of perFile) for (const imp of f.srcImports) {
|
|
99
|
+
if (!bySrc.has(imp.resolved)) bySrc.set(imp.resolved, []);
|
|
100
|
+
bySrc.get(imp.resolved).push({ path: f.path, named: new Set(imp.named), itNames: f.itNames });
|
|
101
|
+
}
|
|
102
|
+
const out = [];
|
|
103
|
+
for (const [src, consumers] of bySrc) {
|
|
104
|
+
if (consumers.length < 2) continue;
|
|
105
|
+
const sorted = [...consumers].sort((a, b) => a.named.size - b.named.size);
|
|
106
|
+
const narrow = sorted[0]; const broad = sorted[sorted.length - 1];
|
|
107
|
+
if (narrow.path === broad.path || narrow.named.size === 0 || broad.named.size === 0) continue;
|
|
108
|
+
if (!(narrow.named.size < broad.named.size && [...narrow.named].every((n) => broad.named.has(n)))) continue;
|
|
109
|
+
// Require ≥50% of narrow's it() names to also appear in broad's. Imports
|
|
110
|
+
// alone are a false-positive trap: two tests can share helpers and assert
|
|
111
|
+
// on disjoint behavior (e.g. tests/budget.test.js vs tests/utils/budget.test.js).
|
|
112
|
+
if (narrow.itNames.size === 0) continue;
|
|
113
|
+
const overlap = [...narrow.itNames].filter((n) => broad.itNames.has(n)).length;
|
|
114
|
+
if (overlap / narrow.itNames.size < 0.5) continue;
|
|
115
|
+
out.push({ category: "subsumed-candidate", confidence: "low", path: narrow.path, detail: `${overlap}/${narrow.itNames.size} it() names also in ${broad.path}; subset of imports from ${src}` });
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function auditTestDiet(rootDir = DEFAULT_ROOT) {
|
|
121
|
+
const files = walkTests(rootDir);
|
|
122
|
+
const perFile = files.map((f) => auditFile(f, rootDir));
|
|
123
|
+
const findings = [];
|
|
124
|
+
for (const f of perFile) for (const x of f.findings) findings.push({ path: f.path, ...x });
|
|
125
|
+
for (const s of findSubsumed(perFile)) findings.push(s);
|
|
126
|
+
return { generatedAt: new Date().toISOString(), rootDir, totals: { filesAudited: files.length, findings: findings.length }, findings };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function renderMarkdown(report) {
|
|
130
|
+
const byCat = new Map();
|
|
131
|
+
for (const f of report.findings) {
|
|
132
|
+
if (!byCat.has(f.category)) byCat.set(f.category, []);
|
|
133
|
+
byCat.get(f.category).push(f);
|
|
134
|
+
}
|
|
135
|
+
const lines = [`# Semantic test-diet audit`, ``, `Generated: ${report.generatedAt}`, `Files audited: ${report.totals.filesAudited}`, `Findings: ${report.totals.findings}`, ``];
|
|
136
|
+
for (const [cat, items] of byCat) {
|
|
137
|
+
lines.push(`## ${cat} (${items.length})`, ``);
|
|
138
|
+
for (const it of items) lines.push(`- \`${it.path}\` — ${it.detail} (confidence: ${it.confidence})`);
|
|
139
|
+
lines.push(``);
|
|
140
|
+
}
|
|
141
|
+
return lines.join("\n");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
145
|
+
const report = auditTestDiet(DEFAULT_ROOT);
|
|
146
|
+
const outDir = join(DEFAULT_ROOT, "audit");
|
|
147
|
+
if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
|
|
148
|
+
writeFileSync(join(outDir, "test-diet-semantic.json"), JSON.stringify(report, null, 2));
|
|
149
|
+
writeFileSync(join(outDir, "test-diet-semantic.md"), renderMarkdown(report));
|
|
150
|
+
console.log(`audit: ${report.totals.filesAudited} files, ${report.totals.findings} findings -> audit/test-diet-semantic.{json,md}`);
|
|
151
|
+
}
|
|
@@ -3,5 +3,16 @@
|
|
|
3
3
|
# never serves stale code. Installed by `kj rag install-hooks`. The marker
|
|
4
4
|
# in this comment is how the installer tells kj-managed hooks from user hooks.
|
|
5
5
|
set -euo pipefail
|
|
6
|
-
if
|
|
7
|
-
kj rag index --since auto >/dev/null 2>&1 || true
|
|
6
|
+
if command -v kj >/dev/null 2>&1; then
|
|
7
|
+
kj rag index --since auto >/dev/null 2>&1 || true
|
|
8
|
+
fi
|
|
9
|
+
|
|
10
|
+
# KJC-TSK-0507 — Refresh the per-project QMD semantic wiki when the merge
|
|
11
|
+
# touched indexable folders (docs/, .reviews/, .karajan/plans/). Background
|
|
12
|
+
# so it never blocks the user's `git pull` / `git merge`.
|
|
13
|
+
if command -v qmd >/dev/null 2>&1; then
|
|
14
|
+
if git diff-tree -r --name-only ORIG_HEAD HEAD 2>/dev/null \
|
|
15
|
+
| grep -qE '^(docs/|\.reviews/|\.karajan/plans/)'; then
|
|
16
|
+
(qmd update >/dev/null 2>&1 || true) &
|
|
17
|
+
fi
|
|
18
|
+
fi
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// AI-slop tells detector — KJC-TSK-0503. Deterministic, offline,
|
|
2
|
+
// regex-based scanner over JS/TS source. Five categories:
|
|
3
|
+
// tautologic-comments, banner-separators, boilerplate-docstrings,
|
|
4
|
+
// magic-fallbacks, redundant-jsdoc. Score 100 = clean.
|
|
5
|
+
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
|
|
9
|
+
const FILE_EXTS = new Set([".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"]);
|
|
10
|
+
const IGNORE_DIRS = new Set([
|
|
11
|
+
"node_modules", "dist", "build", "coverage",
|
|
12
|
+
".git", ".karajan", "public", ".next", ".nuxt", ".vercel", ".cache",
|
|
13
|
+
]);
|
|
14
|
+
const VERB = "(?:returns?|gets?|sets?|fetches?|loads?|saves?|creates?|builds?|makes?|computes?|handles?|checks?|validates?)";
|
|
15
|
+
const LINE_PATTERNS = {
|
|
16
|
+
"banner-separators": /\/[/*]\s*[=*\-_#~]{5,}/,
|
|
17
|
+
"magic-fallbacks": /(\?\?|\|\|)\s*(?:""|''|null|undefined)\s*(?:\)|;|,|$)/,
|
|
18
|
+
"redundant-jsdoc": new RegExp(`@param\\s+\\{[^}]+\\}\\s+(\\w+)\\s+(?:-\\s*)?(?:the\\s+)?\\1\\s+(?:parameter|argument|value)`, "i"),
|
|
19
|
+
};
|
|
20
|
+
const TAUT_RE = new RegExp(`^\\s*//\\s*${VERB}\\s+(?:the\\s+|a\\s+|an\\s+)?(\\w+)\\s*\\.?\\s*$`, "i");
|
|
21
|
+
const DOC_RE = new RegExp(`^\\s*/\\*\\*\\s*${VERB}\\s+(?:the\\s+|a\\s+|an\\s+)?(\\w+)\\s*\\.?\\s*\\*/\\s*$`, "i");
|
|
22
|
+
const DECL_RE = /^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|const|let|var|class)\s+(\w+)/;
|
|
23
|
+
|
|
24
|
+
export const CATEGORIES = Object.freeze([
|
|
25
|
+
"tautologic-comments", "banner-separators", "boilerplate-docstrings",
|
|
26
|
+
"magic-fallbacks", "redundant-jsdoc",
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
function nextDecl(lines, from) {
|
|
30
|
+
for (let j = from; j < Math.min(from + 3, lines.length); j++) {
|
|
31
|
+
const m = lines[j]?.match(DECL_RE);
|
|
32
|
+
if (m) return m[1];
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const stem = (a, b) => { const al = a.toLowerCase(), bl = b.toLowerCase(); return al === bl || al.includes(bl) || bl.includes(al); };
|
|
37
|
+
|
|
38
|
+
export function scanText(relPath, text) {
|
|
39
|
+
const out = [];
|
|
40
|
+
const lines = text.split(/\r?\n/);
|
|
41
|
+
for (let i = 0; i < lines.length; i++) {
|
|
42
|
+
const line = lines[i];
|
|
43
|
+
const lineNo = i + 1;
|
|
44
|
+
for (const [category, re] of Object.entries(LINE_PATTERNS)) {
|
|
45
|
+
if (re.test(line)) out.push({ path: relPath, line: lineNo, category, snippet: line.trim().slice(0, 100) });
|
|
46
|
+
}
|
|
47
|
+
const t = line.match(TAUT_RE);
|
|
48
|
+
if (t) {
|
|
49
|
+
const decl = nextDecl(lines, i + 1);
|
|
50
|
+
if (decl && stem(decl, t[1])) out.push({ path: relPath, line: lineNo, category: "tautologic-comments", snippet: line.trim().slice(0, 100) });
|
|
51
|
+
}
|
|
52
|
+
const d = line.match(DOC_RE);
|
|
53
|
+
if (d) {
|
|
54
|
+
const decl = nextDecl(lines, i + 1);
|
|
55
|
+
if (decl && stem(decl, d[1])) out.push({ path: relPath, line: lineNo, category: "boilerplate-docstrings", snippet: line.trim().slice(0, 100) });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function listSourceFiles(root) {
|
|
62
|
+
const out = [];
|
|
63
|
+
const stack = [root];
|
|
64
|
+
while (stack.length) {
|
|
65
|
+
const dir = stack.pop();
|
|
66
|
+
let entries;
|
|
67
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
68
|
+
for (const e of entries) {
|
|
69
|
+
const full = path.join(dir, e.name);
|
|
70
|
+
if (e.isDirectory()) {
|
|
71
|
+
if (IGNORE_DIRS.has(e.name) || e.name.startsWith(".")) continue;
|
|
72
|
+
stack.push(full);
|
|
73
|
+
} else if (FILE_EXTS.has(path.extname(e.name))) out.push(full);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function collectAiSlop(rootDir, opts = {}) {
|
|
80
|
+
const maxLOC = Number.isFinite(opts.maxLOC) ? opts.maxLOC : 200_000;
|
|
81
|
+
const maxWorst = Number.isFinite(opts.maxWorst) ? opts.maxWorst : 10;
|
|
82
|
+
try {
|
|
83
|
+
const findings = [];
|
|
84
|
+
let totalLOC = 0, filesScanned = 0;
|
|
85
|
+
for (const file of listSourceFiles(rootDir)) {
|
|
86
|
+
if (totalLOC >= maxLOC) break;
|
|
87
|
+
let content;
|
|
88
|
+
try { content = fs.readFileSync(file, "utf-8"); } catch { continue; }
|
|
89
|
+
findings.push(...scanText(path.relative(rootDir, file) || file, content));
|
|
90
|
+
totalLOC += content.split(/\r?\n/).length;
|
|
91
|
+
filesScanned++;
|
|
92
|
+
}
|
|
93
|
+
const byCategory = Object.fromEntries(CATEGORIES.map((c) => [c, 0]));
|
|
94
|
+
for (const f of findings) byCategory[f.category]++;
|
|
95
|
+
const density = totalLOC > 0 ? (findings.length / totalLOC) * 100 : 0;
|
|
96
|
+
const score = Math.max(0, Math.min(100, Math.round(100 - density * 100)));
|
|
97
|
+
const worst = findings.slice().sort((a, b) => (a.path === b.path ? a.line - b.line : a.path.localeCompare(b.path))).slice(0, maxWorst);
|
|
98
|
+
return { available: true, filesScanned, totalLOC, total: findings.length, score, byCategory, worst };
|
|
99
|
+
} catch (err) {
|
|
100
|
+
return { available: false, reason: err?.message || String(err) };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -41,11 +41,28 @@ export function formatDeterministicSummary(ctx) {
|
|
|
41
41
|
if (ctx.circularDeps) lines.push(...formatCircularDepsBlock(ctx.circularDeps));
|
|
42
42
|
if (ctx.deadExports) lines.push(...formatDeadExportsBlock(ctx.deadExports));
|
|
43
43
|
if (ctx.injectionFindings) lines.push(...formatInjectionBlock(ctx.injectionFindings));
|
|
44
|
+
if (ctx.aiSlop) lines.push(...formatAiSlopBlock(ctx.aiSlop));
|
|
44
45
|
if (ctx.webperf) lines.push(...formatWebperfBlock(ctx.webperf));
|
|
45
46
|
|
|
46
47
|
return lines.join("\n");
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
function formatAiSlopBlock(slop) {
|
|
51
|
+
if (!slop.available) return ["### AI-slop tells (deterministic)", `- Status: not available — ${slop.reason || "scan failed"}`, ""];
|
|
52
|
+
const lines = ["### AI-slop tells (deterministic)", `- Files scanned: ${slop.filesScanned ?? 0}`, `- Score: ${slop.score}/100 (100 = clean)`, `- Findings: ${slop.total ?? 0}`];
|
|
53
|
+
if ((slop.total ?? 0) > 0) {
|
|
54
|
+
for (const [cat, n] of Object.entries(slop.byCategory || {})) {
|
|
55
|
+
if (n > 0) lines.push(` - ${cat}: ${n}`);
|
|
56
|
+
}
|
|
57
|
+
if (Array.isArray(slop.worst) && slop.worst.length > 0) {
|
|
58
|
+
lines.push(" - Worst offenders:");
|
|
59
|
+
for (const f of slop.worst) lines.push(` - ${f.path}:${f.line} [${f.category}] ${f.snippet}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
lines.push("");
|
|
63
|
+
return lines;
|
|
64
|
+
}
|
|
65
|
+
|
|
49
66
|
function formatInjectionBlock(inj) {
|
|
50
67
|
if (!inj.available) return ["### Prompt-injection scan", `- Status: not available — ${inj.reason || "scan failed"}`, ""];
|
|
51
68
|
const lines = ["### Prompt-injection scan (specs / domain / onboarding)", `- Files scanned: ${inj.scanned ?? 0}`, `- Findings: ${inj.total ?? 0}`];
|
|
@@ -278,5 +295,6 @@ export function deterministicContextHasFindings(ctx) {
|
|
|
278
295
|
if (ctx.circularDeps?.available && (ctx.circularDeps.total ?? 0) > 0) return true;
|
|
279
296
|
if (ctx.deadExports?.available && (ctx.deadExports.total ?? 0) > 0) return true;
|
|
280
297
|
if (ctx.growthDelta && (Math.abs(ctx.growthDelta.lines || 0) > 100 || Math.abs(ctx.growthDelta.deps || 0) > 0)) return true;
|
|
298
|
+
if (ctx.aiSlop?.available && (ctx.aiSlop.total ?? 0) > 0) return true;
|
|
281
299
|
return false;
|
|
282
300
|
}
|
|
@@ -2,11 +2,44 @@
|
|
|
2
2
|
// One-shot container: detect Docker, auto-pull on first run (cache hit
|
|
3
3
|
// thereafter), execute `assess <repo>` with the project mounted read-only.
|
|
4
4
|
// No compose, no port discovery — assess is a single process that exits.
|
|
5
|
+
//
|
|
6
|
+
// KJC-BUG-0077 — classify failures (docker_missing / image_inaccessible /
|
|
7
|
+
// auth_required / network_error / unknown) and emit an actionable hint
|
|
8
|
+
// alongside the raw stderr so `kj audit` surfaces something better than
|
|
9
|
+
// the opaque "pull access denied" line the registry returns.
|
|
5
10
|
import { runCommand } from "../utils/process.js";
|
|
6
11
|
|
|
7
12
|
const D = { image: "markmishaev76/ai-harness-scorecard:latest", pullTimeoutMs: 300000, assessTimeoutMs: 180000 };
|
|
8
13
|
const pos = (v, d) => (Number(v) > 0 ? Number(v) : d);
|
|
9
14
|
|
|
15
|
+
const DOCKER_FAILURE_PATTERNS = [
|
|
16
|
+
{ reason: "image_inaccessible", re: /pull access denied|repository does not exist|manifest unknown|not found: manifest|denied: requested access/i },
|
|
17
|
+
{ reason: "auth_required", re: /unauthorized|authentication required|login required|requires authentication/i },
|
|
18
|
+
{ reason: "network_error", re: /no such host|connection refused|dial tcp|i\/o timeout|TLS handshake|net\/http|temporary failure in name resolution/i },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export function classifyDockerFailure(stderr = "") {
|
|
22
|
+
const s = String(stderr);
|
|
23
|
+
for (const { reason, re } of DOCKER_FAILURE_PATTERNS) {
|
|
24
|
+
if (re.test(s)) return reason;
|
|
25
|
+
}
|
|
26
|
+
return "unknown";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function hintForReason(reason, image) {
|
|
30
|
+
switch (reason) {
|
|
31
|
+
case "image_inaccessible":
|
|
32
|
+
return `Image \`${image}\` is not accessible from the public registry (it may have been removed, renamed, or made private). Either run \`kj audit --no-harness\` to skip this step, or override \`audit.harness.image\` with a mirror you control.`;
|
|
33
|
+
case "auth_required":
|
|
34
|
+
return `Pulling \`${image}\` requires registry authentication. Run \`docker login <registry>\` first, or override \`audit.harness.image\` with a public mirror.`;
|
|
35
|
+
case "network_error":
|
|
36
|
+
return `Network error reaching the registry while pulling \`${image}\`. Retry once connectivity is back; \`kj audit --no-harness\` skips this step in the meantime.`;
|
|
37
|
+
case "docker_missing":
|
|
38
|
+
return `Docker is not available on this machine. Install Docker (or run \`kj audit --no-harness\` to silence this).`;
|
|
39
|
+
default: return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
10
43
|
export function normalizeHarnessConfig(h = {}) {
|
|
11
44
|
return {
|
|
12
45
|
enabled: h.enabled !== false,
|
|
@@ -32,20 +65,36 @@ export async function ensureHarnessImage(harness = null) {
|
|
|
32
65
|
const cfg = normalizeHarnessConfig(harness || {});
|
|
33
66
|
if (await isHarnessImagePresent(cfg.image)) return { pulled: false, reused: true, image: cfg.image };
|
|
34
67
|
const r = await runCommand("docker", ["pull", cfg.image], { timeout: cfg.pullTimeoutMs });
|
|
35
|
-
if (r.exitCode !== 0)
|
|
68
|
+
if (r.exitCode !== 0) {
|
|
69
|
+
const stderr = r.stderr?.trim() || `exit ${r.exitCode}`;
|
|
70
|
+
const reason = classifyDockerFailure(stderr);
|
|
71
|
+
return { pulled: false, reused: false, image: cfg.image, error: stderr, reason };
|
|
72
|
+
}
|
|
36
73
|
return { pulled: true, reused: false, image: cfg.image };
|
|
37
74
|
}
|
|
38
75
|
|
|
39
76
|
export async function runHarnessAssess(repoPath, harness = null) {
|
|
40
77
|
const cfg = normalizeHarnessConfig(harness || {});
|
|
78
|
+
if (!(await isDockerAvailable())) {
|
|
79
|
+
return { ok: false, reason: "docker_missing", image: cfg.image, error: "Docker daemon is not available", hint: hintForReason("docker_missing", cfg.image) };
|
|
80
|
+
}
|
|
81
|
+
const pull = await ensureHarnessImage(cfg);
|
|
82
|
+
if (pull.error) {
|
|
83
|
+
const reason = pull.reason || "pull_failed";
|
|
84
|
+
return { ok: false, reason, image: cfg.image, error: pull.error, hint: hintForReason(reason, cfg.image) };
|
|
85
|
+
}
|
|
41
86
|
const r = await runCommand(
|
|
42
87
|
"docker",
|
|
43
88
|
["run", "--rm", "-v", `${repoPath}:/repo:ro`, cfg.image, "assess", "/repo", "--format", "json"],
|
|
44
89
|
{ timeout: cfg.assessTimeoutMs },
|
|
45
90
|
);
|
|
46
|
-
if (r.exitCode !== 0)
|
|
91
|
+
if (r.exitCode !== 0) {
|
|
92
|
+
const stderr = r.stderr?.trim() || r.stdout?.trim() || `exit ${r.exitCode}`;
|
|
93
|
+
const reason = classifyDockerFailure(stderr);
|
|
94
|
+
return { ok: false, reason, image: cfg.image, error: stderr, hint: hintForReason(reason, cfg.image) };
|
|
95
|
+
}
|
|
47
96
|
try {
|
|
48
97
|
const parsed = JSON.parse(r.stdout);
|
|
49
98
|
return { ok: true, score: parsed.overall_score, grade: parsed.grade, raw: parsed };
|
|
50
|
-
} catch (e) { return { ok: false, error: `Invalid JSON from harness: ${e.message}` }; }
|
|
99
|
+
} catch (e) { return { ok: false, reason: "invalid_json", error: `Invalid JSON from harness: ${e.message}` }; }
|
|
51
100
|
}
|
|
@@ -22,7 +22,7 @@ export async function runHarnessSection({ projectDir, harnessConfig = null, runA
|
|
|
22
22
|
const cfg = normalizeHarnessConfig(harnessConfig || {});
|
|
23
23
|
if (!cfg.enabled) return { ok: true, skipped: true, reason: "disabled" };
|
|
24
24
|
const res = await runAssess(projectDir, cfg);
|
|
25
|
-
if (!res.ok) return { ok: false, error: res.error };
|
|
25
|
+
if (!res.ok) return { ok: false, error: res.error, reason: res.reason, hint: res.hint, image: cfg.image };
|
|
26
26
|
const outDir = path.join(projectDir, ".karajan", "audit-runs", ts());
|
|
27
27
|
await fs.mkdir(outDir, { recursive: true });
|
|
28
28
|
const scorecardPath = path.join(outDir, "scorecard.json");
|
|
@@ -39,7 +39,11 @@ export async function runHarnessSection({ projectDir, harnessConfig = null, runA
|
|
|
39
39
|
|
|
40
40
|
export function formatHarnessSection(summary) {
|
|
41
41
|
if (!summary || summary.skipped) return "";
|
|
42
|
-
if (!summary.ok)
|
|
42
|
+
if (!summary.ok) {
|
|
43
|
+
const reason = summary.reason ? ` (${summary.reason})` : "";
|
|
44
|
+
const hint = summary.hint ? `\n\n${summary.hint}\n` : "";
|
|
45
|
+
return `## Harness Scorecard\n\n_skipped${reason}: ${summary.error}_${hint}\n`;
|
|
46
|
+
}
|
|
43
47
|
const lines = [
|
|
44
48
|
`## Harness Health Score: ${summary.score}/100 (${summary.grade})`,
|
|
45
49
|
"",
|