karajan-code 2.7.4 → 2.9.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.
Files changed (192) hide show
  1. package/README.md +1 -1
  2. package/package.json +24 -4
  3. package/scripts/diet-baseline.mjs +170 -0
  4. package/scripts/regen-arch-stats.sh +71 -0
  5. package/src/agents/availability.js +15 -9
  6. package/src/agents/claude-agent.js +143 -45
  7. package/src/agents/model-registry.js +1 -1
  8. package/src/audit/agent-readiness.js +240 -0
  9. package/src/audit/basal-cost.js +92 -9
  10. package/src/audit/deterministic-summary.js +200 -0
  11. package/src/audit/osv-findings.js +162 -0
  12. package/src/audit/semgrep-findings.js +117 -0
  13. package/src/audit/sonar-findings.js +81 -0
  14. package/src/audit/webperf-input.js +90 -0
  15. package/src/bootstrap.js +26 -1
  16. package/src/brain/decision-tracker.js +3 -1
  17. package/src/brain/decisor.js +1 -1
  18. package/src/canvas/parse-md.js +238 -0
  19. package/src/canvas/schema.js +101 -0
  20. package/src/canvas/to-flat.js +85 -0
  21. package/src/canvas/validate.js +163 -0
  22. package/src/checks/binaries.js +3 -3
  23. package/src/checks/ci.js +2 -2
  24. package/src/checks/config-files.js +6 -6
  25. package/src/checks/dir-setup.js +4 -0
  26. package/src/checks/mcp-health.js +6 -0
  27. package/src/checks/ports.js +91 -29
  28. package/src/checks/rtk.js +1 -1
  29. package/src/checks/skills.js +5 -1
  30. package/src/checks/sonar.js +2 -2
  31. package/src/checks/system.js +3 -3
  32. package/src/checks/tokens.js +1 -1
  33. package/src/checks/types.js +0 -12
  34. package/src/cli/_shared.js +30 -0
  35. package/src/cli/register-meta.js +218 -0
  36. package/src/cli/register-pipeline.js +212 -0
  37. package/src/cli/register-plan.js +152 -0
  38. package/src/cli/register-roles-skills.js +38 -0
  39. package/src/cli/register-sonar.js +28 -0
  40. package/src/cli.js +83 -468
  41. package/src/commands/agents.js +18 -6
  42. package/src/commands/architect.js +29 -2
  43. package/src/commands/audit.js +265 -30
  44. package/src/commands/board.js +160 -30
  45. package/src/commands/clean.js +83 -0
  46. package/src/commands/code.js +7 -2
  47. package/src/commands/discover.js +7 -2
  48. package/src/commands/doctor.js +1 -1
  49. package/src/commands/init.js +2 -2
  50. package/src/commands/plan/_shared.js +40 -0
  51. package/src/commands/plan/add-hu.js +21 -0
  52. package/src/commands/plan/delete.js +9 -0
  53. package/src/commands/plan/generate.js +284 -0
  54. package/src/commands/plan/index.js +14 -0
  55. package/src/commands/plan/list.js +21 -0
  56. package/src/commands/plan/ready.js +46 -0
  57. package/src/commands/plan/remove-hu.js +17 -0
  58. package/src/commands/plan/show.js +81 -0
  59. package/src/commands/plan/validate.js +20 -0
  60. package/src/commands/plan.js +14 -291
  61. package/src/commands/researcher.js +7 -2
  62. package/src/commands/resume.js +24 -1
  63. package/src/commands/review.js +7 -2
  64. package/src/commands/run.js +52 -1
  65. package/src/commands/status.js +1 -1
  66. package/src/commands/sync.js +75 -0
  67. package/src/commands/triage.js +7 -2
  68. package/src/commands/undo.js +1 -1
  69. package/src/config/defaults.js +213 -0
  70. package/src/config/loader.js +137 -0
  71. package/src/config/overrides.js +199 -0
  72. package/src/config/role-resolver.js +159 -0
  73. package/src/config/test-harness.js +78 -0
  74. package/src/config.js +30 -632
  75. package/src/git/automation.js +2 -2
  76. package/src/guards/output-guard.js +0 -2
  77. package/src/hu/acceptance-runner.js +52 -10
  78. package/src/hu/store.js +11 -0
  79. package/src/mcp/handlers/direct-handlers.js +2 -5
  80. package/src/mcp/handlers/management-handlers.js +48 -4
  81. package/src/mcp/handlers/run-handler.js +9 -8
  82. package/src/mcp/preflight.js +32 -13
  83. package/src/mcp/server-handlers.js +4 -3
  84. package/src/mcp/suggest-handler.js +3 -2
  85. package/src/mcp/tools.js +15 -0
  86. package/src/orchestrator/agent-fallback.js +1 -1
  87. package/src/orchestrator/ci-integration.js +11 -11
  88. package/src/orchestrator/config-init.js +25 -7
  89. package/src/orchestrator/direct-actions.js +7 -3
  90. package/src/orchestrator/drivers/error-recovery.js +287 -0
  91. package/src/orchestrator/drivers/init-context.js +222 -0
  92. package/src/orchestrator/drivers/iteration-loop.js +301 -0
  93. package/src/orchestrator/drivers/iteration-phases/coder-and-refactorer.js +48 -0
  94. package/src/orchestrator/drivers/iteration-phases/guards.js +84 -0
  95. package/src/orchestrator/drivers/iteration-phases/handle-approved.js +29 -0
  96. package/src/orchestrator/drivers/iteration-phases/quality-gates.js +76 -0
  97. package/src/orchestrator/drivers/iteration-phases/reviewer-gate.js +60 -0
  98. package/src/orchestrator/drivers/post-loop.js +370 -0
  99. package/src/orchestrator/drivers/pre-loop-phases/auto-hu-batch.js +116 -0
  100. package/src/orchestrator/drivers/pre-loop-phases/config-deprecations.js +47 -0
  101. package/src/orchestrator/drivers/pre-loop-phases/ensure-addyosmani-skills.js +84 -0
  102. package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +268 -0
  103. package/src/orchestrator/drivers/pre-loop.js +431 -0
  104. package/src/orchestrator/drivers/run-hu-batch.js +367 -0
  105. package/src/orchestrator/feedback-queue.js +0 -1
  106. package/src/orchestrator/flow-control.js +4 -3
  107. package/src/orchestrator/flow-runner.js +121 -2077
  108. package/src/orchestrator/fs-leak-detector.js +156 -0
  109. package/src/orchestrator/hu-sub-pipeline.js +197 -9
  110. package/src/orchestrator/integrations.js +81 -0
  111. package/src/orchestrator/post-loop-stages.js +35 -8
  112. package/src/orchestrator/preflight-checks.js +20 -7
  113. package/src/orchestrator/repair/repair-detector.js +117 -0
  114. package/src/orchestrator/repair/repair-runner.js +142 -0
  115. package/src/orchestrator/reviewer-fallback.js +1 -1
  116. package/src/orchestrator/role-output-compressor.js +0 -1
  117. package/src/orchestrator/session-journal.js +4 -41
  118. package/src/orchestrator/solomon-escalation.js +3 -2
  119. package/src/orchestrator/solomon-rules.js +7 -3
  120. package/src/orchestrator/stages/architect-stage.js +12 -11
  121. package/src/orchestrator/stages/coder-stage.js +56 -7
  122. package/src/orchestrator/stages/hu-reviewer-phases/detect-and-apply-splits.js +127 -0
  123. package/src/orchestrator/stages/hu-reviewer-stage.js +6 -79
  124. package/src/orchestrator/stages/planner-stage.js +1 -1
  125. package/src/orchestrator/stages/research-stage.js +1 -1
  126. package/src/orchestrator/stages/reviewer-stage.js +7 -7
  127. package/src/orchestrator/stages/sonar-stage.js +94 -10
  128. package/src/orchestrator/stages/stage-classes.js +84 -0
  129. package/src/orchestrator/stages/stage-executor.js +1 -1
  130. package/src/orchestrator/stages/triage-stage.js +1 -1
  131. package/src/orchestrator/standby.js +3 -2
  132. package/src/orchestrator/verification-gate.js +20 -12
  133. package/src/plan/adr-generator.js +170 -0
  134. package/src/plan/adr-loader.js +60 -0
  135. package/src/plan/plan-executor.js +19 -1
  136. package/src/plan/plan-hu-ops.js +170 -1
  137. package/src/plan/plan-reviewer.js +199 -0
  138. package/src/plan/plan-schema.js +69 -1
  139. package/src/plan/plan-store.js +40 -17
  140. package/src/plan/spec-detector.js +61 -0
  141. package/src/plan/tests-synthesizer.js +116 -0
  142. package/src/planning-game/client.js +3 -28
  143. package/src/planning-game/decomposition.js +1 -1
  144. package/src/planning-game/pipeline-adapter.js +37 -6
  145. package/src/prompts/audit.js +206 -6
  146. package/src/prompts/coder.js +172 -1
  147. package/src/prompts/hu-reviewer.js +0 -1
  148. package/src/prompts/planner.js +81 -4
  149. package/src/prompts/rtk-snippet.js +1 -1
  150. package/src/review/diff-generator.js +35 -22
  151. package/src/review/snapshot-diff.js +1 -1
  152. package/src/roles/audit-role.js +125 -13
  153. package/src/roles/coder-role.js +25 -3
  154. package/src/roles/planner-role.js +42 -1
  155. package/src/roles/repairer-role.js +181 -0
  156. package/src/roles/tester-role.js +70 -4
  157. package/src/session/mutators.js +304 -0
  158. package/src/session/runtime-overrides.js +59 -0
  159. package/src/{session-store.js → session/store.js} +2 -2
  160. package/src/skills/semantic-detector.js +10 -5
  161. package/src/skills/skill-detector.js +1 -1
  162. package/src/sonar/api.js +1 -1
  163. package/src/sonar/cloud-scanner.js +0 -1
  164. package/src/sonar/credentials.js +14 -6
  165. package/src/sonar/discovery.js +123 -0
  166. package/src/sonar/manager.js +18 -0
  167. package/src/sync/detect-drift.js +113 -0
  168. package/src/tracker-bootstrap.js +44 -0
  169. package/src/utils/agent-detect.js +15 -11
  170. package/src/utils/board-prompt-bridge.js +109 -0
  171. package/src/utils/cli-progress.js +161 -0
  172. package/src/utils/cwd-confirm.js +39 -0
  173. package/src/utils/derive-project-name-from-cwd.js +119 -0
  174. package/src/utils/display/event-handlers.js +1 -1
  175. package/src/utils/display/header.js +1 -1
  176. package/src/utils/display/session-summary.js +3 -3
  177. package/src/utils/display/solomon.js +1 -1
  178. package/src/utils/garbage-collector.js +396 -0
  179. package/src/utils/git.js +16 -4
  180. package/src/utils/locale.js +1 -1
  181. package/src/utils/paths.js +21 -1
  182. package/src/utils/process.js +1 -1
  183. package/src/utils/prompt-project-name.js +35 -0
  184. package/src/utils/role-assigner.js +1 -1
  185. package/src/utils/run-context.js +75 -0
  186. package/src/utils/run-log.js +12 -0
  187. package/src/utils/task-file.js +1 -1
  188. package/src/utils/telemetry.js +13 -1
  189. package/src/webperf/devtools-detect.js +1 -1
  190. package/templates/coder-rules.md +13 -0
  191. package/templates/roles/coder.md +34 -0
  192. package/templates/roles/repairer.md +94 -0
@@ -0,0 +1,240 @@
1
+ /**
2
+ * kj audit --agent-readiness — score a repo for AI-agent readability.
3
+ *
4
+ * Static, deterministic, NO LLM call. Inspired by addyosmani/agentic-seo.
5
+ * Catches the failure modes that make repos invisible to Cursor /
6
+ * Cline / Aider / Claude Code:
7
+ * - no llms.txt → agents have to crawl the README
8
+ * - oversized doc pages → agents truncate or drop them
9
+ * - JS-only docs → agents can't render
10
+ * - broken heading hierarchy → agents misinfer structure
11
+ * - no robots allowlist → some agents skip silently
12
+ *
13
+ * Returns a 0-100 score + per-check detail + top remediation hints.
14
+ * Pure data transformation: filesystem reads in, analysis out.
15
+ */
16
+
17
+ import fs from "node:fs";
18
+ import path from "node:path";
19
+
20
+ const PAGE_BYTE_LIMIT = 32 * 1024; // ≈ 8K tokens, the typical agent budget per fetch
21
+
22
+ /**
23
+ * Catalogue of checks. Each check has:
24
+ * - id — stable identifier
25
+ * - weight — points contributed when ok
26
+ * - run — (rootDir) => { ok, hint, details }
27
+ */
28
+ const CHECKS = [
29
+ {
30
+ id: "llms-txt-present",
31
+ weight: 25,
32
+ description: "llms.txt at repo root",
33
+ run(rootDir) {
34
+ const p = path.join(rootDir, "llms.txt");
35
+ const ok = fs.existsSync(p) && fs.statSync(p).isFile();
36
+ return ok
37
+ ? { ok: true, hint: null, details: { path: p } }
38
+ : { ok: false, hint: "Add llms.txt at repo root with a single-fetch index of docs + commands.", details: {} };
39
+ },
40
+ },
41
+ {
42
+ id: "llms-txt-valid",
43
+ weight: 10,
44
+ description: "llms.txt has at least one section heading and one link",
45
+ run(rootDir) {
46
+ const p = path.join(rootDir, "llms.txt");
47
+ if (!fs.existsSync(p)) return { ok: false, hint: "llms.txt missing — see llms-txt-present.", details: {} };
48
+ const text = safeRead(p);
49
+ const hasSection = /^##\s+/m.test(text);
50
+ const hasLink = /\[[^\]]+\]\([^)]+\)/.test(text);
51
+ const ok = hasSection && hasLink;
52
+ return ok
53
+ ? { ok: true, hint: null, details: { hasSection, hasLink } }
54
+ : { ok: false, hint: "Add `## Section` headings and `[label](path)` links to llms.txt.", details: { hasSection, hasLink } };
55
+ },
56
+ },
57
+ {
58
+ id: "robots-allowlist",
59
+ weight: 10,
60
+ description: "robots.txt explicitly Allows AI crawlers",
61
+ run(rootDir) {
62
+ // Check root or public/.
63
+ const candidates = ["robots.txt", "public/robots.txt", "static/robots.txt"];
64
+ const found = candidates.find((c) => fs.existsSync(path.join(rootDir, c)));
65
+ if (!found) return { ok: false, hint: "Add robots.txt with `User-agent: GPTBot|ClaudeBot|PerplexityBot\\nAllow: /` blocks.", details: {} };
66
+ const text = safeRead(path.join(rootDir, found));
67
+ const aiBots = ["GPTBot", "ClaudeBot", "PerplexityBot", "anthropic-ai", "Google-Extended"];
68
+ const allowed = aiBots.filter((bot) => new RegExp(`User-agent:\\s*${bot}[\\s\\S]*?Allow:\\s*/`, "i").test(text));
69
+ const ok = allowed.length > 0;
70
+ return ok
71
+ ? { ok: true, hint: null, details: { found, allowed } }
72
+ : { ok: false, hint: `robots.txt found at ${found} but no AI crawler explicitly Allowed. Add explicit User-agent: ClaudeBot blocks.`, details: { found, allowed } };
73
+ },
74
+ },
75
+ {
76
+ id: "page-token-budget",
77
+ weight: 15,
78
+ description: "every .md doc fits within ≈ 8K tokens (32 KB raw)",
79
+ run(rootDir) {
80
+ const docsDir = path.join(rootDir, "docs");
81
+ if (!fs.existsSync(docsDir)) {
82
+ // No docs/ at all is its own kind of agent-unfriendly, but
83
+ // not strictly a budget violation. Pass with a hint.
84
+ return { ok: true, hint: "No docs/ directory found. Consider adding one.", details: { docsDirPresent: false } };
85
+ }
86
+ const oversized = listMarkdownFiles(docsDir).filter((f) => fs.statSync(f).size > PAGE_BYTE_LIMIT);
87
+ if (oversized.length === 0) {
88
+ return { ok: true, hint: null, details: { oversizedCount: 0 } };
89
+ }
90
+ const sample = oversized.slice(0, 3).map((f) => `${path.relative(rootDir, f)} (${(fs.statSync(f).size / 1024).toFixed(1)} KB)`);
91
+ return {
92
+ ok: false,
93
+ hint: `Split or trim ${oversized.length} oversized doc(s): ${sample.join(", ")}.`,
94
+ details: { oversizedCount: oversized.length, sample },
95
+ };
96
+ },
97
+ },
98
+ {
99
+ id: "heading-hierarchy",
100
+ weight: 10,
101
+ description: "each .md has exactly one H1 and no skipped levels",
102
+ run(rootDir) {
103
+ const docsDir = path.join(rootDir, "docs");
104
+ if (!fs.existsSync(docsDir)) return { ok: true, hint: null, details: { docsDirPresent: false } };
105
+ const violations = [];
106
+ for (const f of listMarkdownFiles(docsDir)) {
107
+ const text = safeRead(f);
108
+ const h1Count = (text.match(/^#\s+/gm) || []).length;
109
+ if (h1Count !== 1) {
110
+ violations.push({ file: path.relative(rootDir, f), h1Count });
111
+ continue;
112
+ }
113
+ // Check for skipped levels (#### without preceding ###, etc.)
114
+ const headings = [...text.matchAll(/^(#{1,6})\s/gm)].map((m) => m[1].length);
115
+ let prev = 0;
116
+ for (const lvl of headings) {
117
+ if (prev > 0 && lvl > prev + 1) {
118
+ violations.push({ file: path.relative(rootDir, f), reason: `H${prev}→H${lvl} skip` });
119
+ break;
120
+ }
121
+ prev = lvl;
122
+ }
123
+ }
124
+ if (violations.length === 0) {
125
+ return { ok: true, hint: null, details: { violationsCount: 0 } };
126
+ }
127
+ const sample = violations.slice(0, 3);
128
+ return {
129
+ ok: false,
130
+ hint: `Fix heading hierarchy in ${violations.length} doc(s): ${sample.map((v) => v.file).join(", ")}.`,
131
+ details: { violationsCount: violations.length, sample },
132
+ };
133
+ },
134
+ },
135
+ {
136
+ id: "agents-readme",
137
+ weight: 10,
138
+ description: "docs/agents/README.md as agent entry point",
139
+ run(rootDir) {
140
+ const p = path.join(rootDir, "docs/agents/README.md");
141
+ const ok = fs.existsSync(p);
142
+ return ok
143
+ ? { ok: true, hint: null, details: { path: p } }
144
+ : { ok: false, hint: "Create docs/agents/README.md as the agent-facing index (links from llms.txt).", details: {} };
145
+ },
146
+ },
147
+ {
148
+ id: "skill-md-coverage",
149
+ weight: 20,
150
+ description: "at least one SKILL.md per declared CLI capability",
151
+ run(rootDir) {
152
+ const skillDir = path.join(rootDir, "docs/agents");
153
+ if (!fs.existsSync(skillDir)) return { ok: false, hint: "Create docs/agents/ with one SKILL.<command>.md per CLI capability.", details: {} };
154
+ const skillFiles = fs.readdirSync(skillDir).filter((f) => /^SKILL\..+\.md$/.test(f));
155
+ if (skillFiles.length === 0) {
156
+ return { ok: false, hint: "Add SKILL.<command>.md files documenting each CLI capability.", details: { skillFilesCount: 0 } };
157
+ }
158
+ // We don't enumerate the user's own CLI here (out of scope for a
159
+ // generic auditor). At least 1 SKILL.md is the minimum signal.
160
+ return { ok: true, hint: null, details: { skillFilesCount: skillFiles.length } };
161
+ },
162
+ },
163
+ ];
164
+
165
+ function safeRead(p) {
166
+ try { return fs.readFileSync(p, "utf8"); } catch { return ""; }
167
+ }
168
+
169
+ function listMarkdownFiles(dir, out = []) {
170
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
171
+ if (e.name.startsWith(".") || e.name === "node_modules") continue;
172
+ const p = path.join(dir, e.name);
173
+ if (e.isDirectory()) listMarkdownFiles(p, out);
174
+ else if (e.isFile() && e.name.endsWith(".md")) out.push(p);
175
+ }
176
+ return out;
177
+ }
178
+
179
+ /**
180
+ * Run every agent-readiness check against `rootDir`. Returns a
181
+ * report with score, per-check results, and ranked remediation hints.
182
+ *
183
+ * @param {string} rootDir — absolute path to the repo root to audit
184
+ * @returns {{ score: number, checks: object[], topFixes: object[] }}
185
+ */
186
+ export function runAgentReadiness(rootDir) {
187
+ const checks = [];
188
+ let earned = 0;
189
+ let possible = 0;
190
+ for (const c of CHECKS) {
191
+ possible += c.weight;
192
+ let result;
193
+ try {
194
+ result = c.run(rootDir);
195
+ } catch (err) {
196
+ result = { ok: false, hint: `Check threw: ${err.message}`, details: { error: err.message } };
197
+ }
198
+ if (result.ok) earned += c.weight;
199
+ checks.push({
200
+ id: c.id,
201
+ description: c.description,
202
+ weight: c.weight,
203
+ score: result.ok ? c.weight : 0,
204
+ ok: result.ok,
205
+ hint: result.hint,
206
+ details: result.details || {},
207
+ });
208
+ }
209
+ const score = possible === 0 ? 0 : Math.round((earned / possible) * 100);
210
+ const topFixes = checks
211
+ .filter((c) => !c.ok && c.hint)
212
+ .sort((a, b) => b.weight - a.weight)
213
+ .slice(0, 5)
214
+ .map((c, i) => ({
215
+ priority: i + 1,
216
+ checkId: c.id,
217
+ weightLost: c.weight,
218
+ action: c.hint,
219
+ }));
220
+ return { score, possible, earned, checks, topFixes };
221
+ }
222
+
223
+ /**
224
+ * Render the report in plain text for the human terminal.
225
+ */
226
+ export function formatAgentReadinessReport({ score, checks, topFixes }, rootDir) {
227
+ const lines = [`Agent Readiness — ${rootDir}: ${score}/100`, ""];
228
+ for (const c of checks) {
229
+ const tick = c.ok ? "✓" : "✗";
230
+ const pts = `(+${c.score})`.padStart(6);
231
+ lines.push(` ${tick} ${c.description.padEnd(50)} ${pts}${c.hint ? " " + c.hint : ""}`);
232
+ }
233
+ if (topFixes.length > 0) {
234
+ lines.push("", "Top fixes:");
235
+ for (const f of topFixes) {
236
+ lines.push(` ${f.priority}. [${f.checkId}] ${f.action}`);
237
+ }
238
+ }
239
+ return lines.join("\n");
240
+ }
@@ -92,14 +92,68 @@ function runDepcheck(projectDir) {
92
92
  });
93
93
  }
94
94
 
95
+ // Regexes hoisted to module scope so they're compiled once instead of per file.
96
+ // Naming: *Re catches static/syntactic patterns; we still rely on the import set
97
+ // being a superset (false negative > false positive in audit context).
98
+ const EXPORT_RE = /export\s+(?:default\s+)?(?:function|class|const|let|var|async\s+function)\s+(\w+)/g;
99
+ const NAMED_EXPORT_RE = /export\s*\{([^}]+)\}/g;
100
+ const STATIC_NAMED_IMPORT_RE = /import\s+\{([^}]+)\}\s+from/g;
101
+ const DEFAULT_IMPORT_RE = /import\s+(\w+)\s+from/g;
102
+ // Namespace import (`import * as ns from <path>`) — knip-equivalent: mark
103
+ // the target file as fully-consumed because we cannot statically know
104
+ // which `ns.X` accesses happen.
105
+ const NAMESPACE_IMPORT_RE = /import\s+\*\s+as\s+\w+\s+from\s+["']([^"']+)["']/g;
106
+ // Any dynamic `await import(<path>)` — destructured, late-bound, or
107
+ // member-accessed. Conservatively mark the target as fully-consumed:
108
+ // trying to enumerate downstream destructuring/member-access patterns is
109
+ // fragile (the same test may use parenthesised assignment with no const,
110
+ // or bind first and `.foo`-access later). A false-negative here (one
111
+ // extra "live" export we can't prove dead) is always cheaper than a
112
+ // false-positive in audit context.
113
+ const DYNAMIC_IMPORT_RE = /\bawait\s+import\s*\(\s*["']([^"']+)["']\s*\)/g;
114
+ // Re-export bridges (`export { x } from <path>` and `export * from <path>`)
115
+ // — both consume from the target file (the re-exporter is itself another
116
+ // file consumer downstream).
117
+ const RE_EXPORT_NAMED_RE = /export\s*\{([^}]+)\}\s*from\s*["']([^"']+)["']/g;
118
+ const RE_EXPORT_STAR_RE = /export\s*\*\s*from\s*["']([^"']+)["']/g;
119
+ // Export preceded by a JSDoc block with `@internal` is opt-out: documented as
120
+ // dynamic-import-only or test-fixture surface that the regex passes can't see.
121
+ const INTERNAL_EXPORT_RE = /\/\*\*[\s\S]*?@internal[\s\S]*?\*\/\s*export\s+(?:default\s+)?(?:function|class|const|let|var|async\s+function)\s+(\w+)/g;
122
+
123
+ function resolveImportSpecifier(fromFile, specifier) {
124
+ // Only resolve project-relative paths; bare specifiers (npm packages) and
125
+ // node: builtins are not in our exportMap and don't matter here.
126
+ if (!specifier.startsWith(".") && !specifier.startsWith("/")) return null;
127
+ const base = path.resolve(path.dirname(fromFile), specifier);
128
+ // Caller will probe both with and without explicit extensions.
129
+ return base.endsWith(".js") || base.endsWith(".mjs") || base.endsWith(".cjs")
130
+ || base.endsWith(".ts") || base.endsWith(".tsx")
131
+ ? base
132
+ : `${base}.js`;
133
+ }
134
+
135
+ // Strip every string literal (template, double, single) from the content
136
+ // before scanning for export declarations. Test files routinely embed
137
+ // sample source code inside fixtures (template literals for plugin
138
+ // modules, double-quoted diff lines for prompt tests, etc.). At top level
139
+ // `export ...` is a statement, never an expression, so it can never
140
+ // legitimately appear inside a quoted string in real code — meaning the
141
+ // strip is safe for export detection. We do NOT use this stripped view
142
+ // for import detection: the import regexes anchor on `import {...} from`
143
+ // regardless of the path string, but the namespace/dynamic-import passes
144
+ // need the actual path inside the quotes to resolve the target module.
145
+ function stripStringLiterals(content) {
146
+ return content
147
+ .replace(/`[^`]*`/g, "``")
148
+ .replace(/"(?:\\.|[^"\\])*"/g, '""')
149
+ .replace(/'(?:\\.|[^'\\])*'/g, "''");
150
+ }
151
+
95
152
  async function findDeadExports(sourceFiles) {
96
153
  const exportMap = new Map(); // file -> [exportName]
154
+ const internalExportsByFile = new Map(); // file -> Set<exportName>
97
155
  const importedNames = new Set();
98
-
99
- const exportRe = /export\s+(?:default\s+)?(?:function|class|const|let|var|async\s+function)\s+(\w+)/g;
100
- const namedExportRe = /export\s*\{([^}]+)\}/g;
101
- const importRe = /import\s+\{([^}]+)\}\s+from/g;
102
- const defaultImportRe = /import\s+(\w+)\s+from/g;
156
+ const fullyConsumedFiles = new Set(); // resolved file paths reached via `import *` or bound dynamic import
103
157
 
104
158
  for (const file of sourceFiles) {
105
159
  let content;
@@ -108,12 +162,13 @@ async function findDeadExports(sourceFiles) {
108
162
  } catch {
109
163
  continue;
110
164
  }
165
+ const exportContent = stripStringLiterals(content);
111
166
 
112
167
  const exports = [];
113
- for (const match of content.matchAll(exportRe)) {
168
+ for (const match of exportContent.matchAll(EXPORT_RE)) {
114
169
  exports.push(match[1]);
115
170
  }
116
- for (const match of content.matchAll(namedExportRe)) {
171
+ for (const match of exportContent.matchAll(NAMED_EXPORT_RE)) {
117
172
  const names = match[1].split(",").map(n => n.trim().split(/\s+as\s+/).pop().trim());
118
173
  exports.push(...names);
119
174
  }
@@ -121,18 +176,46 @@ async function findDeadExports(sourceFiles) {
121
176
  exportMap.set(file, exports);
122
177
  }
123
178
 
124
- for (const match of content.matchAll(importRe)) {
179
+ // Collect `@internal`-tagged exports up front so the dead-pass can skip them.
180
+ const internalSet = new Set();
181
+ for (const match of exportContent.matchAll(INTERNAL_EXPORT_RE)) {
182
+ internalSet.add(match[1]);
183
+ }
184
+ if (internalSet.size > 0) {
185
+ internalExportsByFile.set(file, internalSet);
186
+ }
187
+
188
+ for (const match of content.matchAll(STATIC_NAMED_IMPORT_RE)) {
125
189
  const names = match[1].split(",").map(n => n.trim().split(/\s+as\s+/)[0].trim());
126
190
  for (const name of names) importedNames.add(name);
127
191
  }
128
- for (const match of content.matchAll(defaultImportRe)) {
192
+ for (const match of content.matchAll(DEFAULT_IMPORT_RE)) {
129
193
  importedNames.add(match[1]);
130
194
  }
195
+ for (const match of content.matchAll(NAMESPACE_IMPORT_RE)) {
196
+ const resolved = resolveImportSpecifier(file, match[1]);
197
+ if (resolved) fullyConsumedFiles.add(resolved);
198
+ }
199
+ for (const match of content.matchAll(DYNAMIC_IMPORT_RE)) {
200
+ const resolved = resolveImportSpecifier(file, match[1]);
201
+ if (resolved) fullyConsumedFiles.add(resolved);
202
+ }
203
+ for (const match of content.matchAll(RE_EXPORT_NAMED_RE)) {
204
+ const names = match[1].split(",").map(n => n.trim().split(/\s+as\s+/)[0].trim());
205
+ for (const name of names) importedNames.add(name);
206
+ }
207
+ for (const match of content.matchAll(RE_EXPORT_STAR_RE)) {
208
+ const resolved = resolveImportSpecifier(file, match[1]);
209
+ if (resolved) fullyConsumedFiles.add(resolved);
210
+ }
131
211
  }
132
212
 
133
213
  const dead = [];
134
214
  for (const [file, exports] of exportMap) {
215
+ if (fullyConsumedFiles.has(file)) continue; // module imported via * or bound await import — can't tell which exports are used
216
+ const internalSet = internalExportsByFile.get(file);
135
217
  for (const name of exports) {
218
+ if (internalSet?.has(name)) continue; // explicitly opted out via @internal
136
219
  if (!importedNames.has(name)) {
137
220
  dead.push({ file, name });
138
221
  }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Deterministic-only summary formatter for `kj audit` two-phase mode
3
+ * (KJC-TSK-0364). Renders the data AuditRole.collectDeterministic()
4
+ * gathers — basalCost, growth delta, stack, Sonar findings, WebPerf —
5
+ * as a self-contained markdown block the user can read BEFORE deciding
6
+ * whether to spend tokens on the LLM analysis.
7
+ *
8
+ * Kept in src/audit/ (not src/commands/) so the same formatter can be
9
+ * reused by future MCP tools and other entrypoints.
10
+ */
11
+
12
+ import { groupIssuesBySeverity } from "./sonar-findings.js";
13
+ import { groupVulnerabilitiesBySeverity } from "./osv-findings.js";
14
+ import { groupFindingsBySeverity as groupSemgrepBySeverity } from "./semgrep-findings.js";
15
+
16
+ const MAX_SAMPLE_DEAD_EXPORTS = 10;
17
+ const MAX_SAMPLE_SONAR_PER_SEVERITY = 5;
18
+ const MAX_SAMPLE_OSV_PER_SEVERITY = 5;
19
+ const MAX_SAMPLE_SEMGREP_PER_SEVERITY = 5;
20
+
21
+ /**
22
+ * @param {{basalCost?: object, growthDelta?: object, stack?: object, sonarFindings?: object, webperf?: object, osvFindings?: object}} ctx
23
+ * @returns {string} markdown block
24
+ */
25
+ export function formatDeterministicSummary(ctx) {
26
+ const lines = ["## Deterministic Findings", ""];
27
+ lines.push("These are the rule-based findings (no LLM). They cost zero tokens and are always factually accurate. The interactive prompt below asks if you want to extend this with an LLM analysis (which will cost tokens).");
28
+ lines.push("");
29
+
30
+ if (ctx.stack) lines.push(...formatStackBlock(ctx.stack));
31
+ if (ctx.basalCost) lines.push(...formatBasalCostBlock(ctx.basalCost, ctx.growthDelta));
32
+ if (ctx.sonarFindings) lines.push(...formatSonarBlock(ctx.sonarFindings));
33
+ if (ctx.osvFindings) lines.push(...formatOsvBlock(ctx.osvFindings));
34
+ if (ctx.semgrepFindings) lines.push(...formatSemgrepBlock(ctx.semgrepFindings));
35
+ if (ctx.webperf) lines.push(...formatWebperfBlock(ctx.webperf));
36
+
37
+ return lines.join("\n");
38
+ }
39
+
40
+ function formatSemgrepBlock(semgrepFindings) {
41
+ if (!semgrepFindings.available) {
42
+ return ["### Semgrep SAST", `- Status: not available — ${semgrepFindings.reason || "semgrep not installed"}`, ""];
43
+ }
44
+ const lines = ["### Semgrep SAST"];
45
+ lines.push(`- Total findings: ${semgrepFindings.total ?? 0}`);
46
+ if ((semgrepFindings.total ?? 0) > 0) {
47
+ const groups = groupSemgrepBySeverity(semgrepFindings.findings || []);
48
+ for (const [severity, findings] of Object.entries(groups)) {
49
+ if (findings.length === 0) continue;
50
+ lines.push(` - ${severity} (${findings.length}):`);
51
+ for (const f of findings.slice(0, MAX_SAMPLE_SEMGREP_PER_SEVERITY)) {
52
+ const loc = f.file ? `${f.file}${f.line ? `:${f.line}` : ""}` : "";
53
+ lines.push(` - ${loc} [${f.rule}]`);
54
+ }
55
+ if (findings.length > MAX_SAMPLE_SEMGREP_PER_SEVERITY) {
56
+ lines.push(` - ... and ${findings.length - MAX_SAMPLE_SEMGREP_PER_SEVERITY} more in ${severity}`);
57
+ }
58
+ }
59
+ }
60
+ lines.push("");
61
+ return lines;
62
+ }
63
+
64
+ function formatOsvBlock(osvFindings) {
65
+ if (!osvFindings.available) {
66
+ return ["### OSV Vulnerabilities", `- Status: not available — ${osvFindings.reason || "osv-scanner not installed"}`, ""];
67
+ }
68
+ const lines = ["### OSV Vulnerabilities (Dependencies)"];
69
+ lines.push(`- Total open: ${osvFindings.total ?? 0}`);
70
+ if ((osvFindings.total ?? 0) > 0) {
71
+ const groups = groupVulnerabilitiesBySeverity(osvFindings.vulnerabilities || []);
72
+ for (const [severity, vulns] of Object.entries(groups)) {
73
+ if (vulns.length === 0) continue;
74
+ lines.push(` - ${severity} (${vulns.length}):`);
75
+ for (const v of vulns.slice(0, MAX_SAMPLE_OSV_PER_SEVERITY)) {
76
+ const where = v.package ? `${v.package}@${v.version || "?"}` : "(unknown)";
77
+ const fixed = v.fixed ? ` → fixed in ${v.fixed}` : "";
78
+ lines.push(` - ${v.id} ${where}${fixed}`);
79
+ }
80
+ if (vulns.length > MAX_SAMPLE_OSV_PER_SEVERITY) {
81
+ lines.push(` - ... and ${vulns.length - MAX_SAMPLE_OSV_PER_SEVERITY} more in ${severity}`);
82
+ }
83
+ }
84
+ }
85
+ lines.push("");
86
+ return lines;
87
+ }
88
+
89
+ function formatStackBlock(stack) {
90
+ if (!stack || (!stack.frameworks?.length && !stack.language && !stack.isFrontend && !stack.isBackend)) return [];
91
+ const lines = ["### Project Stack"];
92
+ if (stack.language) lines.push(`- Language: ${stack.language}`);
93
+ if (stack.frameworks?.length) lines.push(`- Frameworks: ${stack.frameworks.join(", ")}`);
94
+ const tier = stack.isFullstack ? "fullstack (frontend + backend)"
95
+ : stack.isFrontend ? "frontend-only"
96
+ : stack.isBackend ? "backend-only"
97
+ : "unknown";
98
+ lines.push(`- Tier: ${tier}`);
99
+ lines.push("");
100
+ return lines;
101
+ }
102
+
103
+ function formatBasalCostBlock(basalCost, growthDelta) {
104
+ const lines = ["### Basal Cost"];
105
+ lines.push(`- Source: ${basalCost.totalLines} lines across ${basalCost.totalFiles} files`);
106
+ const deps = basalCost.dependencies || {};
107
+ if (deps.total) lines.push(`- Dependencies: ${deps.total} (${deps.dependencies} prod + ${deps.devDependencies} dev)`);
108
+
109
+ const unused = basalCost.unusedDependencies?.unused;
110
+ if (Array.isArray(unused) && unused.length > 0) {
111
+ lines.push(`- Unused dependencies (${unused.length}): ${unused.join(", ")}`);
112
+ }
113
+
114
+ const dead = Array.isArray(basalCost.deadExports) ? basalCost.deadExports : [];
115
+ if (dead.length > 0) {
116
+ lines.push(`- Dead exports: ${dead.length}`);
117
+ for (const de of dead.slice(0, MAX_SAMPLE_DEAD_EXPORTS)) {
118
+ lines.push(` - \`${de.name}\` in ${de.file}`);
119
+ }
120
+ if (dead.length > MAX_SAMPLE_DEAD_EXPORTS) {
121
+ lines.push(` - ... and ${dead.length - MAX_SAMPLE_DEAD_EXPORTS} more`);
122
+ }
123
+ } else {
124
+ lines.push("- Dead exports: 0");
125
+ }
126
+
127
+ if (growthDelta) {
128
+ lines.push("");
129
+ lines.push("**Growth since last audit**");
130
+ lines.push(`- Lines: ${growthDelta.lines >= 0 ? "+" : ""}${growthDelta.lines}`);
131
+ lines.push(`- Files: ${growthDelta.files >= 0 ? "+" : ""}${growthDelta.files}`);
132
+ lines.push(`- Dependencies: ${growthDelta.deps >= 0 ? "+" : ""}${growthDelta.deps}`);
133
+ if (growthDelta.since) lines.push(`- Since: ${growthDelta.since}`);
134
+ }
135
+ lines.push("");
136
+ return lines;
137
+ }
138
+
139
+ function formatSonarBlock(sonarFindings) {
140
+ if (!sonarFindings.available) {
141
+ return ["### SonarQube", `- Status: not available — ${sonarFindings.reason || "host unreachable"}`, ""];
142
+ }
143
+ const lines = ["### SonarQube"];
144
+ if (sonarFindings.qualityGate?.status) lines.push(`- Quality gate: ${sonarFindings.qualityGate.status}`);
145
+ lines.push(`- Open issues: ${sonarFindings.total ?? 0}`);
146
+ if ((sonarFindings.total ?? 0) > 0) {
147
+ const groups = groupIssuesBySeverity(sonarFindings.issues || []);
148
+ for (const [severity, issues] of Object.entries(groups)) {
149
+ if (issues.length === 0) continue;
150
+ lines.push(` - ${severity} (${issues.length}):`);
151
+ for (const issue of issues.slice(0, MAX_SAMPLE_SONAR_PER_SEVERITY)) {
152
+ const loc = issue.component ? `${issue.component}${issue.line ? `:${issue.line}` : ""}` : "";
153
+ const rule = issue.rule ? ` [${issue.rule}]` : "";
154
+ lines.push(` - ${loc}${rule} ${issue.message || ""}`.trim());
155
+ }
156
+ if (issues.length > MAX_SAMPLE_SONAR_PER_SEVERITY) {
157
+ lines.push(` - ... and ${issues.length - MAX_SAMPLE_SONAR_PER_SEVERITY} more in ${severity}`);
158
+ }
159
+ }
160
+ }
161
+ lines.push("");
162
+ return lines;
163
+ }
164
+
165
+ function formatWebperfBlock(webperf) {
166
+ if (!webperf.available) {
167
+ return ["### WebPerf", `- Status: not collected — ${webperf.reason || "no frontend signal"}`, ""];
168
+ }
169
+ const lines = ["### WebPerf"];
170
+ if (webperf.mode === "cwv-result" && webperf.cwv) {
171
+ lines.push(`- Mode: live CWV measurement`);
172
+ if (webperf.url) lines.push(`- URL: ${webperf.url}`);
173
+ lines.push(`- Verdict: ${webperf.cwv.pass ? "PASS" : "FAIL"}`);
174
+ for (const [metric, score] of Object.entries(webperf.cwv.scores || {})) {
175
+ lines.push(` - ${metric.toUpperCase()}: ${score.value} (${score.rating})`);
176
+ }
177
+ } else {
178
+ lines.push(`- Mode: static hints only (no live CWV measurement available)`);
179
+ lines.push(`- LLM phase will scan source for bundle/lazy/img-opt patterns when activated`);
180
+ }
181
+ lines.push("");
182
+ return lines;
183
+ }
184
+
185
+ /**
186
+ * Detect whether the deterministic context found any signal worth showing.
187
+ * If everything came back empty/unavailable, the CLI can skip the
188
+ * "Continue with LLM?" prompt — there's nothing to compare against.
189
+ */
190
+ export function deterministicContextHasFindings(ctx) {
191
+ if (!ctx) return false;
192
+ if (ctx.basalCost?.deadExports?.length > 0) return true;
193
+ if (ctx.basalCost?.unusedDependencies?.unused?.length > 0) return true;
194
+ if (ctx.sonarFindings?.available && (ctx.sonarFindings.total ?? 0) > 0) return true;
195
+ if (ctx.sonarFindings?.qualityGate?.status === "ERROR") return true;
196
+ if (ctx.osvFindings?.available && (ctx.osvFindings.total ?? 0) > 0) return true;
197
+ if (ctx.semgrepFindings?.available && (ctx.semgrepFindings.total ?? 0) > 0) return true;
198
+ if (ctx.growthDelta && (Math.abs(ctx.growthDelta.lines || 0) > 100 || Math.abs(ctx.growthDelta.deps || 0) > 0)) return true;
199
+ return false;
200
+ }