@harness-engineering/core 0.21.1 → 0.21.3

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.mjs CHANGED
@@ -41,7 +41,7 @@ import {
41
41
  runAll,
42
42
  validateDependencies,
43
43
  violationId
44
- } from "./chunk-BQUWXBGR.mjs";
44
+ } from "./chunk-4W4FRAA6.mjs";
45
45
 
46
46
  // src/index.ts
47
47
  export * from "@harness-engineering/types";
@@ -84,15 +84,15 @@ function validateConfig(data, schema) {
84
84
  let message = "Configuration validation failed";
85
85
  const suggestions = [];
86
86
  if (firstError) {
87
- const path31 = firstError.path.join(".");
88
- const pathDisplay = path31 ? ` at "${path31}"` : "";
87
+ const path34 = firstError.path.join(".");
88
+ const pathDisplay = path34 ? ` at "${path34}"` : "";
89
89
  if (firstError.code === "invalid_type") {
90
90
  const received = firstError.received;
91
91
  const expected = firstError.expected;
92
92
  if (received === "undefined") {
93
93
  code = "MISSING_FIELD";
94
94
  message = `Missing required field${pathDisplay}: ${firstError.message}`;
95
- suggestions.push(`Field "${path31}" is required and must be of type "${expected}"`);
95
+ suggestions.push(`Field "${path34}" is required and must be of type "${expected}"`);
96
96
  } else {
97
97
  code = "INVALID_TYPE";
98
98
  message = `Invalid type${pathDisplay}: ${firstError.message}`;
@@ -149,21 +149,11 @@ function validateCommitMessage(message, format = "conventional") {
149
149
  issues: []
150
150
  });
151
151
  }
152
- function validateConventionalCommit(message) {
153
- const lines = message.split("\n");
154
- const headerLine = lines[0];
155
- if (!headerLine) {
156
- const error = createError(
157
- "VALIDATION_FAILED",
158
- "Commit message header cannot be empty",
159
- { message },
160
- ["Provide a commit message with at least a header line"]
161
- );
162
- return Err(error);
163
- }
152
+ function parseConventionalHeader(message, headerLine) {
164
153
  const match = headerLine.match(CONVENTIONAL_PATTERN);
165
- if (!match) {
166
- const error = createError(
154
+ if (match) return Ok(match);
155
+ return Err(
156
+ createError(
167
157
  "VALIDATION_FAILED",
168
158
  "Commit message does not follow conventional format",
169
159
  { message, header: headerLine },
@@ -172,13 +162,10 @@ function validateConventionalCommit(message) {
172
162
  "Valid types: " + VALID_TYPES.join(", "),
173
163
  "Example: feat(core): add new feature"
174
164
  ]
175
- );
176
- return Err(error);
177
- }
178
- const type = match[1];
179
- const scope = match[3];
180
- const breaking = match[4] === "!";
181
- const description = match[5];
165
+ )
166
+ );
167
+ }
168
+ function collectCommitIssues(type, description) {
182
169
  const issues = [];
183
170
  if (!VALID_TYPES.includes(type)) {
184
171
  issues.push(`Invalid commit type "${type}". Valid types: ${VALID_TYPES.join(", ")}`);
@@ -186,34 +173,50 @@ function validateConventionalCommit(message) {
186
173
  if (!description || description.trim() === "") {
187
174
  issues.push("Commit description cannot be empty");
188
175
  }
189
- let hasBreakingChange = breaking;
190
- if (lines.length > 1) {
191
- const body = lines.slice(1).join("\n");
192
- if (body.includes("BREAKING CHANGE:")) {
193
- hasBreakingChange = true;
194
- }
176
+ return issues;
177
+ }
178
+ function hasBreakingChangeInBody(lines) {
179
+ if (lines.length <= 1) return false;
180
+ return lines.slice(1).join("\n").includes("BREAKING CHANGE:");
181
+ }
182
+ function validateConventionalCommit(message) {
183
+ const lines = message.split("\n");
184
+ const headerLine = lines[0];
185
+ if (!headerLine) {
186
+ return Err(
187
+ createError(
188
+ "VALIDATION_FAILED",
189
+ "Commit message header cannot be empty",
190
+ { message },
191
+ ["Provide a commit message with at least a header line"]
192
+ )
193
+ );
195
194
  }
195
+ const matchResult = parseConventionalHeader(message, headerLine);
196
+ if (!matchResult.ok) return matchResult;
197
+ const match = matchResult.value;
198
+ const type = match[1];
199
+ const scope = match[3];
200
+ const breaking = match[4] === "!";
201
+ const description = match[5];
202
+ const issues = collectCommitIssues(type, description);
196
203
  if (issues.length > 0) {
197
- let errorMessage = `Commit message validation failed: ${issues.join("; ")}`;
198
- if (issues.some((issue) => issue.includes("description cannot be empty"))) {
199
- errorMessage = `Commit message validation failed: ${issues.join("; ")}`;
200
- }
201
- const error = createError(
202
- "VALIDATION_FAILED",
203
- errorMessage,
204
- { message, issues, type, scope },
205
- ["Review and fix the validation issues above"]
204
+ return Err(
205
+ createError(
206
+ "VALIDATION_FAILED",
207
+ `Commit message validation failed: ${issues.join("; ")}`,
208
+ { message, issues, type, scope },
209
+ ["Review and fix the validation issues above"]
210
+ )
206
211
  );
207
- return Err(error);
208
212
  }
209
- const result = {
213
+ return Ok({
210
214
  valid: true,
211
215
  type,
212
216
  ...scope && { scope },
213
- breaking: hasBreakingChange,
217
+ breaking: breaking || hasBreakingChangeInBody(lines),
214
218
  issues: []
215
- };
216
- return Ok(result);
219
+ });
217
220
  }
218
221
 
219
222
  // src/context/types.ts
@@ -308,27 +311,27 @@ function extractSections(content) {
308
311
  }
309
312
  return sections.map((section) => buildAgentMapSection(section, lines));
310
313
  }
311
- function isExternalLink(path31) {
312
- return path31.startsWith("http://") || path31.startsWith("https://") || path31.startsWith("#") || path31.startsWith("mailto:");
314
+ function isExternalLink(path34) {
315
+ return path34.startsWith("http://") || path34.startsWith("https://") || path34.startsWith("#") || path34.startsWith("mailto:");
313
316
  }
314
317
  function resolveLinkPath(linkPath, baseDir) {
315
318
  return linkPath.startsWith(".") ? join(baseDir, linkPath) : linkPath;
316
319
  }
317
- async function validateAgentsMap(path31 = "./AGENTS.md") {
318
- const contentResult = await readFileContent(path31);
320
+ async function validateAgentsMap(path34 = "./AGENTS.md") {
321
+ const contentResult = await readFileContent(path34);
319
322
  if (!contentResult.ok) {
320
323
  return Err(
321
324
  createError(
322
325
  "PARSE_ERROR",
323
326
  `Failed to read AGENTS.md: ${contentResult.error.message}`,
324
- { path: path31 },
327
+ { path: path34 },
325
328
  ["Ensure the file exists", "Check file permissions"]
326
329
  )
327
330
  );
328
331
  }
329
332
  const content = contentResult.value;
330
333
  const sections = extractSections(content);
331
- const baseDir = dirname(path31);
334
+ const baseDir = dirname(path34);
332
335
  const sectionTitles = sections.map((s) => s.title);
333
336
  const missingSections = REQUIRED_SECTIONS.filter(
334
337
  (required) => !sectionTitles.some((title) => title.toLowerCase().includes(required.toLowerCase()))
@@ -469,8 +472,8 @@ async function checkDocCoverage(domain, options = {}) {
469
472
 
470
473
  // src/context/knowledge-map.ts
471
474
  import { join as join2, basename as basename2 } from "path";
472
- function suggestFix(path31, existingFiles) {
473
- const targetName = basename2(path31).toLowerCase();
475
+ function suggestFix(path34, existingFiles) {
476
+ const targetName = basename2(path34).toLowerCase();
474
477
  const similar = existingFiles.find((file) => {
475
478
  const fileName = basename2(file).toLowerCase();
476
479
  return fileName.includes(targetName) || targetName.includes(fileName);
@@ -478,7 +481,7 @@ function suggestFix(path31, existingFiles) {
478
481
  if (similar) {
479
482
  return `Did you mean "${similar}"?`;
480
483
  }
481
- return `Create the file "${path31}" or remove the link`;
484
+ return `Create the file "${path34}" or remove the link`;
482
485
  }
483
486
  async function validateKnowledgeMap(rootDir = process.cwd()) {
484
487
  const agentsPath = join2(rootDir, "AGENTS.md");
@@ -671,6 +674,47 @@ var NODE_TYPE_TO_CATEGORY = {
671
674
  prompt: "systemPrompt",
672
675
  system: "systemPrompt"
673
676
  };
677
+ function makeZeroWeights() {
678
+ return {
679
+ systemPrompt: 0,
680
+ projectManifest: 0,
681
+ taskSpec: 0,
682
+ activeCode: 0,
683
+ interfaces: 0,
684
+ reserve: 0
685
+ };
686
+ }
687
+ function normalizeRatios(ratios) {
688
+ const sum = Object.values(ratios).reduce((s, r) => s + r, 0);
689
+ if (sum === 0) return;
690
+ for (const key of Object.keys(ratios)) {
691
+ ratios[key] = ratios[key] / sum;
692
+ }
693
+ }
694
+ function enforceMinimumRatios(ratios, min) {
695
+ for (const key of Object.keys(ratios)) {
696
+ if (ratios[key] < min) ratios[key] = min;
697
+ }
698
+ }
699
+ function applyGraphDensity(ratios, graphDensity) {
700
+ const weights = makeZeroWeights();
701
+ for (const [nodeType, count] of Object.entries(graphDensity)) {
702
+ const category = NODE_TYPE_TO_CATEGORY[nodeType];
703
+ if (category) weights[category] += count;
704
+ }
705
+ const totalWeight = Object.values(weights).reduce((s, w) => s + w, 0);
706
+ if (totalWeight === 0) return;
707
+ const MIN = 0.01;
708
+ for (const key of Object.keys(ratios)) {
709
+ ratios[key] = weights[key] > 0 ? weights[key] / totalWeight : MIN;
710
+ }
711
+ if (ratios.reserve < DEFAULT_RATIOS.reserve) ratios.reserve = DEFAULT_RATIOS.reserve;
712
+ if (ratios.systemPrompt < DEFAULT_RATIOS.systemPrompt)
713
+ ratios.systemPrompt = DEFAULT_RATIOS.systemPrompt;
714
+ normalizeRatios(ratios);
715
+ enforceMinimumRatios(ratios, MIN);
716
+ normalizeRatios(ratios);
717
+ }
674
718
  function contextBudget(totalTokens, overrides, graphDensity) {
675
719
  const ratios = {
676
720
  systemPrompt: DEFAULT_RATIOS.systemPrompt,
@@ -681,50 +725,7 @@ function contextBudget(totalTokens, overrides, graphDensity) {
681
725
  reserve: DEFAULT_RATIOS.reserve
682
726
  };
683
727
  if (graphDensity) {
684
- const categoryWeights = {
685
- systemPrompt: 0,
686
- projectManifest: 0,
687
- taskSpec: 0,
688
- activeCode: 0,
689
- interfaces: 0,
690
- reserve: 0
691
- };
692
- for (const [nodeType, count] of Object.entries(graphDensity)) {
693
- const category = NODE_TYPE_TO_CATEGORY[nodeType];
694
- if (category) {
695
- categoryWeights[category] += count;
696
- }
697
- }
698
- const totalWeight = Object.values(categoryWeights).reduce((sum, w) => sum + w, 0);
699
- if (totalWeight > 0) {
700
- const MIN_ALLOCATION = 0.01;
701
- for (const key of Object.keys(ratios)) {
702
- if (categoryWeights[key] > 0) {
703
- ratios[key] = categoryWeights[key] / totalWeight;
704
- } else {
705
- ratios[key] = MIN_ALLOCATION;
706
- }
707
- }
708
- if (ratios.reserve < DEFAULT_RATIOS.reserve) {
709
- ratios.reserve = DEFAULT_RATIOS.reserve;
710
- }
711
- if (ratios.systemPrompt < DEFAULT_RATIOS.systemPrompt) {
712
- ratios.systemPrompt = DEFAULT_RATIOS.systemPrompt;
713
- }
714
- const ratioSum = Object.values(ratios).reduce((sum, r) => sum + r, 0);
715
- for (const key of Object.keys(ratios)) {
716
- ratios[key] = ratios[key] / ratioSum;
717
- }
718
- for (const key of Object.keys(ratios)) {
719
- if (ratios[key] < MIN_ALLOCATION) {
720
- ratios[key] = MIN_ALLOCATION;
721
- }
722
- }
723
- const finalSum = Object.values(ratios).reduce((sum, r) => sum + r, 0);
724
- for (const key of Object.keys(ratios)) {
725
- ratios[key] = ratios[key] / finalSum;
726
- }
727
- }
728
+ applyGraphDensity(ratios, graphDensity);
728
729
  }
729
730
  if (overrides) {
730
731
  let overrideSum = 0;
@@ -830,8 +831,8 @@ function createBoundaryValidator(schema, name) {
830
831
  return Ok(result.data);
831
832
  }
832
833
  const suggestions = result.error.issues.map((issue) => {
833
- const path31 = issue.path.join(".");
834
- return path31 ? `${path31}: ${issue.message}` : issue.message;
834
+ const path34 = issue.path.join(".");
835
+ return path34 ? `${path34}: ${issue.message}` : issue.message;
835
836
  });
836
837
  return Err(
837
838
  createError(
@@ -1031,21 +1032,23 @@ function extractBundle(manifest, config) {
1031
1032
  }
1032
1033
 
1033
1034
  // src/constraints/sharing/merge.ts
1035
+ function arraysEqual(a, b) {
1036
+ if (a.length !== b.length) return false;
1037
+ return a.every((val, i) => deepEqual(val, b[i]));
1038
+ }
1039
+ function objectsEqual(a, b) {
1040
+ const keysA = Object.keys(a);
1041
+ const keysB = Object.keys(b);
1042
+ if (keysA.length !== keysB.length) return false;
1043
+ return keysA.every((key) => deepEqual(a[key], b[key]));
1044
+ }
1034
1045
  function deepEqual(a, b) {
1035
1046
  if (a === b) return true;
1036
1047
  if (typeof a !== typeof b) return false;
1037
1048
  if (typeof a !== "object" || a === null || b === null) return false;
1038
- if (Array.isArray(a) && Array.isArray(b)) {
1039
- if (a.length !== b.length) return false;
1040
- return a.every((val, i) => deepEqual(val, b[i]));
1041
- }
1049
+ if (Array.isArray(a) && Array.isArray(b)) return arraysEqual(a, b);
1042
1050
  if (Array.isArray(a) !== Array.isArray(b)) return false;
1043
- const keysA = Object.keys(a);
1044
- const keysB = Object.keys(b);
1045
- if (keysA.length !== keysB.length) return false;
1046
- return keysA.every(
1047
- (key) => deepEqual(a[key], b[key])
1048
- );
1051
+ return objectsEqual(a, b);
1049
1052
  }
1050
1053
  function stringArraysEqual(a, b) {
1051
1054
  if (a.length !== b.length) return false;
@@ -1463,11 +1466,11 @@ function processExportListSpecifiers(exportDecl, exports) {
1463
1466
  var TypeScriptParser = class {
1464
1467
  name = "typescript";
1465
1468
  extensions = [".ts", ".tsx", ".mts", ".cts"];
1466
- async parseFile(path31) {
1467
- const contentResult = await readFileContent(path31);
1469
+ async parseFile(path34) {
1470
+ const contentResult = await readFileContent(path34);
1468
1471
  if (!contentResult.ok) {
1469
1472
  return Err(
1470
- createParseError("NOT_FOUND", `File not found: ${path31}`, { path: path31 }, [
1473
+ createParseError("NOT_FOUND", `File not found: ${path34}`, { path: path34 }, [
1471
1474
  "Check that the file exists",
1472
1475
  "Verify the path is correct"
1473
1476
  ])
@@ -1477,7 +1480,7 @@ var TypeScriptParser = class {
1477
1480
  const ast = parse(contentResult.value, {
1478
1481
  loc: true,
1479
1482
  range: true,
1480
- jsx: path31.endsWith(".tsx"),
1483
+ jsx: path34.endsWith(".tsx"),
1481
1484
  errorOnUnknownASTType: false
1482
1485
  });
1483
1486
  return Ok({
@@ -1488,7 +1491,7 @@ var TypeScriptParser = class {
1488
1491
  } catch (e) {
1489
1492
  const error = e;
1490
1493
  return Err(
1491
- createParseError("SYNTAX_ERROR", `Failed to parse ${path31}: ${error.message}`, { path: path31 }, [
1494
+ createParseError("SYNTAX_ERROR", `Failed to parse ${path34}: ${error.message}`, { path: path34 }, [
1492
1495
  "Check for syntax errors in the file",
1493
1496
  "Ensure valid TypeScript syntax"
1494
1497
  ])
@@ -1673,22 +1676,22 @@ function extractInlineRefs(content) {
1673
1676
  }
1674
1677
  return refs;
1675
1678
  }
1676
- async function parseDocumentationFile(path31) {
1677
- const contentResult = await readFileContent(path31);
1679
+ async function parseDocumentationFile(path34) {
1680
+ const contentResult = await readFileContent(path34);
1678
1681
  if (!contentResult.ok) {
1679
1682
  return Err(
1680
1683
  createEntropyError(
1681
1684
  "PARSE_ERROR",
1682
- `Failed to read documentation file: ${path31}`,
1683
- { file: path31 },
1685
+ `Failed to read documentation file: ${path34}`,
1686
+ { file: path34 },
1684
1687
  ["Check that the file exists"]
1685
1688
  )
1686
1689
  );
1687
1690
  }
1688
1691
  const content = contentResult.value;
1689
- const type = path31.endsWith(".md") ? "markdown" : "text";
1692
+ const type = path34.endsWith(".md") ? "markdown" : "text";
1690
1693
  return Ok({
1691
- path: path31,
1694
+ path: path34,
1692
1695
  type,
1693
1696
  content,
1694
1697
  codeBlocks: extractCodeBlocks(content),
@@ -1698,17 +1701,22 @@ async function parseDocumentationFile(path31) {
1698
1701
  function makeInternalSymbol(name, type, line) {
1699
1702
  return { name, type, line, references: 0, calledBy: [] };
1700
1703
  }
1704
+ function extractFunctionSymbol(node, line) {
1705
+ if (node.id?.name) return [makeInternalSymbol(node.id.name, "function", line)];
1706
+ return [];
1707
+ }
1708
+ function extractVariableSymbols(node, line) {
1709
+ return (node.declarations || []).filter((decl) => decl.id?.name).map((decl) => makeInternalSymbol(decl.id.name, "variable", line));
1710
+ }
1711
+ function extractClassSymbol(node, line) {
1712
+ if (node.id?.name) return [makeInternalSymbol(node.id.name, "class", line)];
1713
+ return [];
1714
+ }
1701
1715
  function extractSymbolsFromNode(node) {
1702
1716
  const line = node.loc?.start?.line || 0;
1703
- if (node.type === "FunctionDeclaration" && node.id?.name) {
1704
- return [makeInternalSymbol(node.id.name, "function", line)];
1705
- }
1706
- if (node.type === "VariableDeclaration") {
1707
- return (node.declarations || []).filter((decl) => decl.id?.name).map((decl) => makeInternalSymbol(decl.id.name, "variable", line));
1708
- }
1709
- if (node.type === "ClassDeclaration" && node.id?.name) {
1710
- return [makeInternalSymbol(node.id.name, "class", line)];
1711
- }
1717
+ if (node.type === "FunctionDeclaration") return extractFunctionSymbol(node, line);
1718
+ if (node.type === "VariableDeclaration") return extractVariableSymbols(node, line);
1719
+ if (node.type === "ClassDeclaration") return extractClassSymbol(node, line);
1712
1720
  return [];
1713
1721
  }
1714
1722
  function extractInternalSymbols(ast) {
@@ -1717,21 +1725,17 @@ function extractInternalSymbols(ast) {
1717
1725
  const nodes = body.body;
1718
1726
  return nodes.flatMap(extractSymbolsFromNode);
1719
1727
  }
1728
+ function toJSDocComment(comment) {
1729
+ if (comment.type !== "Block" || !comment.value?.startsWith("*")) return null;
1730
+ return { content: comment.value, line: comment.loc?.start?.line || 0 };
1731
+ }
1720
1732
  function extractJSDocComments(ast) {
1721
- const comments = [];
1722
1733
  const body = ast.body;
1723
- if (body?.comments) {
1724
- for (const comment of body.comments) {
1725
- if (comment.type === "Block" && comment.value?.startsWith("*")) {
1726
- const jsDocComment = {
1727
- content: comment.value,
1728
- line: comment.loc?.start?.line || 0
1729
- };
1730
- comments.push(jsDocComment);
1731
- }
1732
- }
1733
- }
1734
- return comments;
1734
+ if (!body?.comments) return [];
1735
+ return body.comments.flatMap((c) => {
1736
+ const doc = toJSDocComment(c);
1737
+ return doc ? [doc] : [];
1738
+ });
1735
1739
  }
1736
1740
  function buildExportMap(files) {
1737
1741
  const byFile = /* @__PURE__ */ new Map();
@@ -1746,41 +1750,42 @@ function buildExportMap(files) {
1746
1750
  }
1747
1751
  return { byFile, byName };
1748
1752
  }
1749
- function extractAllCodeReferences(docs) {
1753
+ var CODE_BLOCK_LANGUAGES = /* @__PURE__ */ new Set(["typescript", "ts", "javascript", "js"]);
1754
+ function refsFromInlineRefs(doc) {
1755
+ return doc.inlineRefs.map((inlineRef) => ({
1756
+ docFile: doc.path,
1757
+ line: inlineRef.line,
1758
+ column: inlineRef.column,
1759
+ reference: inlineRef.reference,
1760
+ context: "inline"
1761
+ }));
1762
+ }
1763
+ function refsFromCodeBlock(docPath, block) {
1764
+ if (!CODE_BLOCK_LANGUAGES.has(block.language)) return [];
1750
1765
  const refs = [];
1751
- for (const doc of docs) {
1752
- for (const inlineRef of doc.inlineRefs) {
1766
+ const importRegex = /import\s+\{([^}]+)\}\s+from/g;
1767
+ let match;
1768
+ while ((match = importRegex.exec(block.content)) !== null) {
1769
+ const group = match[1];
1770
+ if (group === void 0) continue;
1771
+ for (const name of group.split(",").map((n) => n.trim())) {
1753
1772
  refs.push({
1754
- docFile: doc.path,
1755
- line: inlineRef.line,
1756
- column: inlineRef.column,
1757
- reference: inlineRef.reference,
1758
- context: "inline"
1773
+ docFile: docPath,
1774
+ line: block.line,
1775
+ column: 0,
1776
+ reference: name,
1777
+ context: "code-block"
1759
1778
  });
1760
1779
  }
1761
- for (const block of doc.codeBlocks) {
1762
- if (block.language === "typescript" || block.language === "ts" || block.language === "javascript" || block.language === "js") {
1763
- const importRegex = /import\s+\{([^}]+)\}\s+from/g;
1764
- let match;
1765
- while ((match = importRegex.exec(block.content)) !== null) {
1766
- const matchedGroup = match[1];
1767
- if (matchedGroup === void 0) continue;
1768
- const names = matchedGroup.split(",").map((n) => n.trim());
1769
- for (const name of names) {
1770
- refs.push({
1771
- docFile: doc.path,
1772
- line: block.line,
1773
- column: 0,
1774
- reference: name,
1775
- context: "code-block"
1776
- });
1777
- }
1778
- }
1779
- }
1780
- }
1781
1780
  }
1782
1781
  return refs;
1783
1782
  }
1783
+ function refsFromCodeBlocks(doc) {
1784
+ return doc.codeBlocks.flatMap((block) => refsFromCodeBlock(doc.path, block));
1785
+ }
1786
+ function extractAllCodeReferences(docs) {
1787
+ return docs.flatMap((doc) => [...refsFromInlineRefs(doc), ...refsFromCodeBlocks(doc)]);
1788
+ }
1784
1789
  async function buildSnapshot(config) {
1785
1790
  const startTime = Date.now();
1786
1791
  const parser = config.parser || new TypeScriptParser();
@@ -1986,44 +1991,52 @@ async function checkStructureDrift(snapshot, _config) {
1986
1991
  }
1987
1992
  return drifts;
1988
1993
  }
1994
+ function computeDriftSeverity(driftCount) {
1995
+ if (driftCount === 0) return "none";
1996
+ if (driftCount <= 3) return "low";
1997
+ if (driftCount <= 10) return "medium";
1998
+ return "high";
1999
+ }
2000
+ function buildGraphDriftReport(graphDriftData) {
2001
+ const drifts = [];
2002
+ for (const target of graphDriftData.missingTargets) {
2003
+ drifts.push({
2004
+ type: "api-signature",
2005
+ docFile: target,
2006
+ line: 0,
2007
+ reference: target,
2008
+ context: "graph-missing-target",
2009
+ issue: "NOT_FOUND",
2010
+ details: `Graph node "${target}" has no matching code target`,
2011
+ confidence: "high"
2012
+ });
2013
+ }
2014
+ for (const edge of graphDriftData.staleEdges) {
2015
+ drifts.push({
2016
+ type: "api-signature",
2017
+ docFile: edge.docNodeId,
2018
+ line: 0,
2019
+ reference: edge.codeNodeId,
2020
+ context: `graph-stale-edge:${edge.edgeType}`,
2021
+ issue: "NOT_FOUND",
2022
+ details: `Stale edge from doc "${edge.docNodeId}" to code "${edge.codeNodeId}" (${edge.edgeType})`,
2023
+ confidence: "medium"
2024
+ });
2025
+ }
2026
+ return Ok({
2027
+ drifts,
2028
+ stats: {
2029
+ docsScanned: graphDriftData.staleEdges.length,
2030
+ referencesChecked: graphDriftData.staleEdges.length + graphDriftData.missingTargets.length,
2031
+ driftsFound: drifts.length,
2032
+ byType: { api: drifts.length, example: 0, structure: 0 }
2033
+ },
2034
+ severity: computeDriftSeverity(drifts.length)
2035
+ });
2036
+ }
1989
2037
  async function detectDocDrift(snapshot, config, graphDriftData) {
1990
2038
  if (graphDriftData) {
1991
- const drifts2 = [];
1992
- for (const target of graphDriftData.missingTargets) {
1993
- drifts2.push({
1994
- type: "api-signature",
1995
- docFile: target,
1996
- line: 0,
1997
- reference: target,
1998
- context: "graph-missing-target",
1999
- issue: "NOT_FOUND",
2000
- details: `Graph node "${target}" has no matching code target`,
2001
- confidence: "high"
2002
- });
2003
- }
2004
- for (const edge of graphDriftData.staleEdges) {
2005
- drifts2.push({
2006
- type: "api-signature",
2007
- docFile: edge.docNodeId,
2008
- line: 0,
2009
- reference: edge.codeNodeId,
2010
- context: `graph-stale-edge:${edge.edgeType}`,
2011
- issue: "NOT_FOUND",
2012
- details: `Stale edge from doc "${edge.docNodeId}" to code "${edge.codeNodeId}" (${edge.edgeType})`,
2013
- confidence: "medium"
2014
- });
2015
- }
2016
- const severity2 = drifts2.length === 0 ? "none" : drifts2.length <= 3 ? "low" : drifts2.length <= 10 ? "medium" : "high";
2017
- return Ok({
2018
- drifts: drifts2,
2019
- stats: {
2020
- docsScanned: graphDriftData.staleEdges.length,
2021
- referencesChecked: graphDriftData.staleEdges.length + graphDriftData.missingTargets.length,
2022
- driftsFound: drifts2.length,
2023
- byType: { api: drifts2.length, example: 0, structure: 0 }
2024
- },
2025
- severity: severity2
2026
- });
2039
+ return buildGraphDriftReport(graphDriftData);
2027
2040
  }
2028
2041
  const fullConfig = { ...DEFAULT_DRIFT_CONFIG, ...config };
2029
2042
  const drifts = [];
@@ -2072,6 +2085,23 @@ function resolveImportToFile(importSource, fromFile, snapshot) {
2072
2085
  }
2073
2086
  return null;
2074
2087
  }
2088
+ function enqueueResolved(sources, current, snapshot, visited, queue) {
2089
+ for (const item of sources) {
2090
+ if (!item.source) continue;
2091
+ const resolved = resolveImportToFile(item.source, current, snapshot);
2092
+ if (resolved && !visited.has(resolved)) {
2093
+ queue.push(resolved);
2094
+ }
2095
+ }
2096
+ }
2097
+ function processReachabilityNode(current, snapshot, reachability, visited, queue) {
2098
+ reachability.set(current, true);
2099
+ const sourceFile = snapshot.files.find((f) => f.path === current);
2100
+ if (!sourceFile) return;
2101
+ enqueueResolved(sourceFile.imports, current, snapshot, visited, queue);
2102
+ const reExports = sourceFile.exports.filter((e) => e.isReExport);
2103
+ enqueueResolved(reExports, current, snapshot, visited, queue);
2104
+ }
2075
2105
  function buildReachabilityMap(snapshot) {
2076
2106
  const reachability = /* @__PURE__ */ new Map();
2077
2107
  for (const file of snapshot.files) {
@@ -2083,23 +2113,7 @@ function buildReachabilityMap(snapshot) {
2083
2113
  const current = queue.shift();
2084
2114
  if (visited.has(current)) continue;
2085
2115
  visited.add(current);
2086
- reachability.set(current, true);
2087
- const sourceFile = snapshot.files.find((f) => f.path === current);
2088
- if (!sourceFile) continue;
2089
- for (const imp of sourceFile.imports) {
2090
- const resolved = resolveImportToFile(imp.source, current, snapshot);
2091
- if (resolved && !visited.has(resolved)) {
2092
- queue.push(resolved);
2093
- }
2094
- }
2095
- for (const exp of sourceFile.exports) {
2096
- if (exp.isReExport && exp.source) {
2097
- const resolved = resolveImportToFile(exp.source, current, snapshot);
2098
- if (resolved && !visited.has(resolved)) {
2099
- queue.push(resolved);
2100
- }
2101
- }
2102
- }
2116
+ processReachabilityNode(current, snapshot, reachability, visited, queue);
2103
2117
  }
2104
2118
  return reachability;
2105
2119
  }
@@ -2169,21 +2183,27 @@ function findDeadExports(snapshot, usageMap, reachability) {
2169
2183
  }
2170
2184
  return deadExports;
2171
2185
  }
2186
+ function maxLineOfValue(value) {
2187
+ if (Array.isArray(value)) {
2188
+ return value.reduce((m, item) => Math.max(m, findMaxLineInNode(item)), 0);
2189
+ }
2190
+ if (value && typeof value === "object") {
2191
+ return findMaxLineInNode(value);
2192
+ }
2193
+ return 0;
2194
+ }
2195
+ function maxLineOfNodeKeys(node) {
2196
+ let max = 0;
2197
+ for (const key of Object.keys(node)) {
2198
+ max = Math.max(max, maxLineOfValue(node[key]));
2199
+ }
2200
+ return max;
2201
+ }
2172
2202
  function findMaxLineInNode(node) {
2173
2203
  if (!node || typeof node !== "object") return 0;
2174
2204
  const n = node;
2175
- let maxLine = n.loc?.end?.line ?? 0;
2176
- for (const key of Object.keys(node)) {
2177
- const value = node[key];
2178
- if (Array.isArray(value)) {
2179
- for (const item of value) {
2180
- maxLine = Math.max(maxLine, findMaxLineInNode(item));
2181
- }
2182
- } else if (value && typeof value === "object") {
2183
- maxLine = Math.max(maxLine, findMaxLineInNode(value));
2184
- }
2185
- }
2186
- return maxLine;
2205
+ const locLine = n.loc?.end?.line ?? 0;
2206
+ return Math.max(locLine, maxLineOfNodeKeys(node));
2187
2207
  }
2188
2208
  function countLinesFromAST(ast) {
2189
2209
  if (!ast.body || !Array.isArray(ast.body)) return 1;
@@ -2257,54 +2277,59 @@ function findDeadInternals(snapshot, _reachability) {
2257
2277
  }
2258
2278
  return deadInternals;
2259
2279
  }
2260
- async function detectDeadCode(snapshot, graphDeadCodeData) {
2261
- if (graphDeadCodeData) {
2262
- const deadFiles2 = [];
2263
- const deadExports2 = [];
2264
- const fileTypes = /* @__PURE__ */ new Set(["file", "module"]);
2265
- const exportTypes = /* @__PURE__ */ new Set(["function", "class", "method", "interface", "variable"]);
2266
- for (const node of graphDeadCodeData.unreachableNodes) {
2267
- if (fileTypes.has(node.type)) {
2268
- deadFiles2.push({
2269
- path: node.path || node.id,
2270
- reason: "NO_IMPORTERS",
2271
- exportCount: 0,
2272
- lineCount: 0
2273
- });
2274
- } else if (exportTypes.has(node.type)) {
2275
- const exportType = node.type === "method" ? "function" : node.type;
2276
- deadExports2.push({
2277
- file: node.path || node.id,
2278
- name: node.name,
2279
- line: 0,
2280
- type: exportType,
2281
- isDefault: false,
2282
- reason: "NO_IMPORTERS"
2283
- });
2284
- }
2285
- }
2286
- const reachableCount = graphDeadCodeData.reachableNodeIds instanceof Set ? graphDeadCodeData.reachableNodeIds.size : graphDeadCodeData.reachableNodeIds.length;
2287
- const fileNodes = graphDeadCodeData.unreachableNodes.filter((n) => fileTypes.has(n.type));
2288
- const exportNodes = graphDeadCodeData.unreachableNodes.filter((n) => exportTypes.has(n.type));
2289
- const totalFiles = reachableCount + fileNodes.length;
2290
- const totalExports2 = exportNodes.length + (reachableCount > 0 ? reachableCount : 0);
2291
- const report2 = {
2292
- deadExports: deadExports2,
2293
- deadFiles: deadFiles2,
2294
- deadInternals: [],
2295
- unusedImports: [],
2296
- stats: {
2297
- filesAnalyzed: totalFiles,
2298
- entryPointsUsed: [],
2299
- totalExports: totalExports2,
2300
- deadExportCount: deadExports2.length,
2301
- totalFiles,
2302
- deadFileCount: deadFiles2.length,
2303
- estimatedDeadLines: 0
2304
- }
2305
- };
2306
- return Ok(report2);
2280
+ var FILE_TYPES = /* @__PURE__ */ new Set(["file", "module"]);
2281
+ var EXPORT_TYPES = /* @__PURE__ */ new Set(["function", "class", "method", "interface", "variable"]);
2282
+ function classifyUnreachableNode(node, deadFiles, deadExports) {
2283
+ if (FILE_TYPES.has(node.type)) {
2284
+ deadFiles.push({
2285
+ path: node.path || node.id,
2286
+ reason: "NO_IMPORTERS",
2287
+ exportCount: 0,
2288
+ lineCount: 0
2289
+ });
2290
+ } else if (EXPORT_TYPES.has(node.type)) {
2291
+ const exportType = node.type === "method" ? "function" : node.type;
2292
+ deadExports.push({
2293
+ file: node.path || node.id,
2294
+ name: node.name,
2295
+ line: 0,
2296
+ type: exportType,
2297
+ isDefault: false,
2298
+ reason: "NO_IMPORTERS"
2299
+ });
2307
2300
  }
2301
+ }
2302
+ function computeGraphReportStats(data, deadFiles, deadExports) {
2303
+ const reachableCount = data.reachableNodeIds instanceof Set ? data.reachableNodeIds.size : data.reachableNodeIds.length;
2304
+ const fileNodes = data.unreachableNodes.filter((n) => FILE_TYPES.has(n.type));
2305
+ const exportNodes = data.unreachableNodes.filter((n) => EXPORT_TYPES.has(n.type));
2306
+ const totalFiles = reachableCount + fileNodes.length;
2307
+ const totalExports = exportNodes.length + (reachableCount > 0 ? reachableCount : 0);
2308
+ return {
2309
+ filesAnalyzed: totalFiles,
2310
+ entryPointsUsed: [],
2311
+ totalExports,
2312
+ deadExportCount: deadExports.length,
2313
+ totalFiles,
2314
+ deadFileCount: deadFiles.length,
2315
+ estimatedDeadLines: 0
2316
+ };
2317
+ }
2318
+ function buildReportFromGraph(data) {
2319
+ const deadFiles = [];
2320
+ const deadExports = [];
2321
+ for (const node of data.unreachableNodes) {
2322
+ classifyUnreachableNode(node, deadFiles, deadExports);
2323
+ }
2324
+ return {
2325
+ deadExports,
2326
+ deadFiles,
2327
+ deadInternals: [],
2328
+ unusedImports: [],
2329
+ stats: computeGraphReportStats(data, deadFiles, deadExports)
2330
+ };
2331
+ }
2332
+ function buildReportFromSnapshot(snapshot) {
2308
2333
  const reachability = buildReachabilityMap(snapshot);
2309
2334
  const usageMap = buildExportUsageMap(snapshot);
2310
2335
  const deadExports = findDeadExports(snapshot, usageMap, reachability);
@@ -2316,7 +2341,7 @@ async function detectDeadCode(snapshot, graphDeadCodeData) {
2316
2341
  0
2317
2342
  );
2318
2343
  const estimatedDeadLines = deadFiles.reduce((acc, file) => acc + file.lineCount, 0);
2319
- const report = {
2344
+ return {
2320
2345
  deadExports,
2321
2346
  deadFiles,
2322
2347
  deadInternals,
@@ -2331,6 +2356,9 @@ async function detectDeadCode(snapshot, graphDeadCodeData) {
2331
2356
  estimatedDeadLines
2332
2357
  }
2333
2358
  };
2359
+ }
2360
+ async function detectDeadCode(snapshot, graphDeadCodeData) {
2361
+ const report = graphDeadCodeData ? buildReportFromGraph(graphDeadCodeData) : buildReportFromSnapshot(snapshot);
2334
2362
  return Ok(report);
2335
2363
  }
2336
2364
 
@@ -2614,48 +2642,52 @@ async function detectSizeBudgetViolations(rootDir, config) {
2614
2642
  }
2615
2643
 
2616
2644
  // src/entropy/fixers/suggestions.ts
2645
+ function deadFileSuggestion(file) {
2646
+ return {
2647
+ type: "delete",
2648
+ priority: "high",
2649
+ source: "dead-code",
2650
+ relatedIssues: [`dead-file:${file.path}`],
2651
+ title: `Remove dead file: ${file.path.split("/").pop()}`,
2652
+ description: `This file is not imported by any other file and can be safely removed.`,
2653
+ files: [file.path],
2654
+ steps: [`Delete ${file.path}`, "Run tests to verify no regressions"],
2655
+ whyManual: "File deletion requires verification that no dynamic imports exist"
2656
+ };
2657
+ }
2658
+ function deadExportSuggestion(exp) {
2659
+ return {
2660
+ type: "refactor",
2661
+ priority: "medium",
2662
+ source: "dead-code",
2663
+ relatedIssues: [`dead-export:${exp.file}:${exp.name}`],
2664
+ title: `Remove unused export: ${exp.name}`,
2665
+ description: `The export "${exp.name}" is not used anywhere. Consider removing it.`,
2666
+ files: [exp.file],
2667
+ steps: [`Remove export "${exp.name}" from ${exp.file}`, "Run tests to verify no regressions"],
2668
+ whyManual: "Export removal may affect external consumers not in scope"
2669
+ };
2670
+ }
2671
+ function unusedImportSuggestion(imp) {
2672
+ const plural = imp.specifiers.length > 1;
2673
+ return {
2674
+ type: "delete",
2675
+ priority: "medium",
2676
+ source: "dead-code",
2677
+ relatedIssues: [`unused-import:${imp.file}:${imp.specifiers.join(",")}`],
2678
+ title: `Remove unused import${plural ? "s" : ""}: ${imp.specifiers.join(", ")}`,
2679
+ description: `The import${plural ? "s" : ""} from "${imp.source}" ${plural ? "are" : "is"} not used.`,
2680
+ files: [imp.file],
2681
+ steps: imp.isFullyUnused ? [`Remove entire import line from ${imp.file}`] : [`Remove unused specifiers (${imp.specifiers.join(", ")}) from import statement`],
2682
+ whyManual: "Import removal can be auto-fixed"
2683
+ };
2684
+ }
2617
2685
  function generateDeadCodeSuggestions(report) {
2618
- const suggestions = [];
2619
- for (const file of report.deadFiles) {
2620
- suggestions.push({
2621
- type: "delete",
2622
- priority: "high",
2623
- source: "dead-code",
2624
- relatedIssues: [`dead-file:${file.path}`],
2625
- title: `Remove dead file: ${file.path.split("/").pop()}`,
2626
- description: `This file is not imported by any other file and can be safely removed.`,
2627
- files: [file.path],
2628
- steps: [`Delete ${file.path}`, "Run tests to verify no regressions"],
2629
- whyManual: "File deletion requires verification that no dynamic imports exist"
2630
- });
2631
- }
2632
- for (const exp of report.deadExports) {
2633
- suggestions.push({
2634
- type: "refactor",
2635
- priority: "medium",
2636
- source: "dead-code",
2637
- relatedIssues: [`dead-export:${exp.file}:${exp.name}`],
2638
- title: `Remove unused export: ${exp.name}`,
2639
- description: `The export "${exp.name}" is not used anywhere. Consider removing it.`,
2640
- files: [exp.file],
2641
- steps: [`Remove export "${exp.name}" from ${exp.file}`, "Run tests to verify no regressions"],
2642
- whyManual: "Export removal may affect external consumers not in scope"
2643
- });
2644
- }
2645
- for (const imp of report.unusedImports) {
2646
- suggestions.push({
2647
- type: "delete",
2648
- priority: "medium",
2649
- source: "dead-code",
2650
- relatedIssues: [`unused-import:${imp.file}:${imp.specifiers.join(",")}`],
2651
- title: `Remove unused import${imp.specifiers.length > 1 ? "s" : ""}: ${imp.specifiers.join(", ")}`,
2652
- description: `The import${imp.specifiers.length > 1 ? "s" : ""} from "${imp.source}" ${imp.specifiers.length > 1 ? "are" : "is"} not used.`,
2653
- files: [imp.file],
2654
- steps: imp.isFullyUnused ? [`Remove entire import line from ${imp.file}`] : [`Remove unused specifiers (${imp.specifiers.join(", ")}) from import statement`],
2655
- whyManual: "Import removal can be auto-fixed"
2656
- });
2657
- }
2658
- return suggestions;
2686
+ return [
2687
+ ...report.deadFiles.map(deadFileSuggestion),
2688
+ ...report.deadExports.map(deadExportSuggestion),
2689
+ ...report.unusedImports.map(unusedImportSuggestion)
2690
+ ];
2659
2691
  }
2660
2692
  function generateDriftSuggestions(report) {
2661
2693
  const suggestions = [];
@@ -3098,43 +3130,55 @@ async function createBackup(filePath, backupDir) {
3098
3130
  );
3099
3131
  }
3100
3132
  }
3133
+ async function applyDeleteFile(fix, config) {
3134
+ if (config.createBackup && config.backupDir) {
3135
+ const backupResult = await createBackup(fix.file, config.backupDir);
3136
+ if (!backupResult.ok) return Err({ fix, error: backupResult.error.message });
3137
+ }
3138
+ await unlink2(fix.file);
3139
+ return Ok(void 0);
3140
+ }
3141
+ async function applyDeleteLines(fix) {
3142
+ if (fix.line !== void 0) {
3143
+ const content = await readFile3(fix.file, "utf-8");
3144
+ const lines = content.split("\n");
3145
+ lines.splice(fix.line - 1, 1);
3146
+ await writeFile3(fix.file, lines.join("\n"));
3147
+ }
3148
+ }
3149
+ async function applyReplace(fix) {
3150
+ if (fix.oldContent && fix.newContent !== void 0) {
3151
+ const content = await readFile3(fix.file, "utf-8");
3152
+ await writeFile3(fix.file, content.replace(fix.oldContent, fix.newContent));
3153
+ }
3154
+ }
3155
+ async function applyInsert(fix) {
3156
+ if (fix.line !== void 0 && fix.newContent) {
3157
+ const content = await readFile3(fix.file, "utf-8");
3158
+ const lines = content.split("\n");
3159
+ lines.splice(fix.line - 1, 0, fix.newContent);
3160
+ await writeFile3(fix.file, lines.join("\n"));
3161
+ }
3162
+ }
3101
3163
  async function applySingleFix(fix, config) {
3102
3164
  if (config.dryRun) {
3103
3165
  return Ok(fix);
3104
3166
  }
3105
3167
  try {
3106
3168
  switch (fix.action) {
3107
- case "delete-file":
3108
- if (config.createBackup && config.backupDir) {
3109
- const backupResult = await createBackup(fix.file, config.backupDir);
3110
- if (!backupResult.ok) {
3111
- return Err({ fix, error: backupResult.error.message });
3112
- }
3113
- }
3114
- await unlink2(fix.file);
3169
+ case "delete-file": {
3170
+ const result = await applyDeleteFile(fix, config);
3171
+ if (!result.ok) return result;
3115
3172
  break;
3173
+ }
3116
3174
  case "delete-lines":
3117
- if (fix.line !== void 0) {
3118
- const content = await readFile3(fix.file, "utf-8");
3119
- const lines = content.split("\n");
3120
- lines.splice(fix.line - 1, 1);
3121
- await writeFile3(fix.file, lines.join("\n"));
3122
- }
3175
+ await applyDeleteLines(fix);
3123
3176
  break;
3124
3177
  case "replace":
3125
- if (fix.oldContent && fix.newContent !== void 0) {
3126
- const content = await readFile3(fix.file, "utf-8");
3127
- const newContent = content.replace(fix.oldContent, fix.newContent);
3128
- await writeFile3(fix.file, newContent);
3129
- }
3178
+ await applyReplace(fix);
3130
3179
  break;
3131
3180
  case "insert":
3132
- if (fix.line !== void 0 && fix.newContent) {
3133
- const content = await readFile3(fix.file, "utf-8");
3134
- const lines = content.split("\n");
3135
- lines.splice(fix.line - 1, 0, fix.newContent);
3136
- await writeFile3(fix.file, lines.join("\n"));
3137
- }
3181
+ await applyInsert(fix);
3138
3182
  break;
3139
3183
  }
3140
3184
  return Ok(fix);
@@ -3307,6 +3351,21 @@ function applyHotspotDowngrade(finding, hotspot) {
3307
3351
  }
3308
3352
  return finding;
3309
3353
  }
3354
+ function mergeGroup(group) {
3355
+ if (group.length === 1) return [group[0]];
3356
+ const deadCode = group.find((f) => f.concern === "dead-code");
3357
+ const arch = group.find((f) => f.concern === "architecture");
3358
+ if (deadCode && arch) {
3359
+ return [
3360
+ {
3361
+ ...deadCode,
3362
+ description: `${deadCode.description} (also violates architecture: ${arch.type})`,
3363
+ suggestion: deadCode.fixAction ? `${deadCode.fixAction} (resolves both dead code and architecture violation)` : deadCode.suggestion
3364
+ }
3365
+ ];
3366
+ }
3367
+ return group;
3368
+ }
3310
3369
  function deduplicateCleanupFindings(findings) {
3311
3370
  const byFileAndLine = /* @__PURE__ */ new Map();
3312
3371
  for (const f of findings) {
@@ -3317,21 +3376,7 @@ function deduplicateCleanupFindings(findings) {
3317
3376
  }
3318
3377
  const result = [];
3319
3378
  for (const group of byFileAndLine.values()) {
3320
- if (group.length === 1) {
3321
- result.push(group[0]);
3322
- continue;
3323
- }
3324
- const deadCode = group.find((f) => f.concern === "dead-code");
3325
- const arch = group.find((f) => f.concern === "architecture");
3326
- if (deadCode && arch) {
3327
- result.push({
3328
- ...deadCode,
3329
- description: `${deadCode.description} (also violates architecture: ${arch.type})`,
3330
- suggestion: deadCode.fixAction ? `${deadCode.fixAction} (resolves both dead code and architecture violation)` : deadCode.suggestion
3331
- });
3332
- } else {
3333
- result.push(...group);
3334
- }
3379
+ result.push(...mergeGroup(group));
3335
3380
  }
3336
3381
  return result;
3337
3382
  }
@@ -3704,6 +3749,32 @@ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", ".git"]);
3704
3749
  var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx"]);
3705
3750
  var FUNCTION_DECL_RE = /(?:export\s+)?(?:async\s+)?function\s+(\w+)/;
3706
3751
  var CONST_DECL_RE = /(?:export\s+)?(?:const|let)\s+(\w+)\s*=/;
3752
+ function mergeGraphInferred(highFanInFunctions, seen) {
3753
+ let added = 0;
3754
+ for (const item of highFanInFunctions) {
3755
+ const key = `${item.file}::${item.function}`;
3756
+ if (!seen.has(key)) {
3757
+ seen.set(key, {
3758
+ file: item.file,
3759
+ function: item.function,
3760
+ source: "graph-inferred",
3761
+ fanIn: item.fanIn
3762
+ });
3763
+ added++;
3764
+ }
3765
+ }
3766
+ return added;
3767
+ }
3768
+ function isCommentOrBlank(line) {
3769
+ return line === "" || line === "*/" || line === "*" || line.startsWith("*") || line.startsWith("//");
3770
+ }
3771
+ function matchDeclarationName(line) {
3772
+ const funcMatch = line.match(FUNCTION_DECL_RE);
3773
+ if (funcMatch?.[1]) return funcMatch[1];
3774
+ const constMatch = line.match(CONST_DECL_RE);
3775
+ if (constMatch?.[1]) return constMatch[1];
3776
+ return null;
3777
+ }
3707
3778
  var CriticalPathResolver = class {
3708
3779
  projectRoot;
3709
3780
  constructor(projectRoot) {
@@ -3716,27 +3787,12 @@ var CriticalPathResolver = class {
3716
3787
  const key = `${entry.file}::${entry.function}`;
3717
3788
  seen.set(key, entry);
3718
3789
  }
3719
- let graphInferred = 0;
3720
- if (graphData) {
3721
- for (const item of graphData.highFanInFunctions) {
3722
- const key = `${item.file}::${item.function}`;
3723
- if (!seen.has(key)) {
3724
- seen.set(key, {
3725
- file: item.file,
3726
- function: item.function,
3727
- source: "graph-inferred",
3728
- fanIn: item.fanIn
3729
- });
3730
- graphInferred++;
3731
- }
3732
- }
3733
- }
3790
+ const graphInferred = graphData ? mergeGraphInferred(graphData.highFanInFunctions, seen) : 0;
3734
3791
  const entries = Array.from(seen.values());
3735
- const annotatedCount = annotated.length;
3736
3792
  return {
3737
3793
  entries,
3738
3794
  stats: {
3739
- annotated: annotatedCount,
3795
+ annotated: annotated.length,
3740
3796
  graphInferred,
3741
3797
  total: entries.length
3742
3798
  }
@@ -3763,6 +3819,14 @@ var CriticalPathResolver = class {
3763
3819
  }
3764
3820
  }
3765
3821
  }
3822
+ resolveFunctionName(lines, fromIndex) {
3823
+ for (let j = fromIndex; j < lines.length; j++) {
3824
+ const nextLine = lines[j].trim();
3825
+ if (isCommentOrBlank(nextLine)) continue;
3826
+ return matchDeclarationName(nextLine);
3827
+ }
3828
+ return null;
3829
+ }
3766
3830
  scanFile(filePath, entries) {
3767
3831
  let content;
3768
3832
  try {
@@ -3773,30 +3837,10 @@ var CriticalPathResolver = class {
3773
3837
  const lines = content.split("\n");
3774
3838
  const relativePath = path.relative(this.projectRoot, filePath).replace(/\\/g, "/");
3775
3839
  for (let i = 0; i < lines.length; i++) {
3776
- const line = lines[i];
3777
- if (!line.includes("@perf-critical")) continue;
3778
- for (let j = i + 1; j < lines.length; j++) {
3779
- const nextLine = lines[j].trim();
3780
- if (nextLine === "" || nextLine === "*/" || nextLine === "*") continue;
3781
- if (nextLine.startsWith("*") || nextLine.startsWith("//")) continue;
3782
- const funcMatch = nextLine.match(FUNCTION_DECL_RE);
3783
- if (funcMatch && funcMatch[1]) {
3784
- entries.push({
3785
- file: relativePath,
3786
- function: funcMatch[1],
3787
- source: "annotation"
3788
- });
3789
- } else {
3790
- const constMatch = nextLine.match(CONST_DECL_RE);
3791
- if (constMatch && constMatch[1]) {
3792
- entries.push({
3793
- file: relativePath,
3794
- function: constMatch[1],
3795
- source: "annotation"
3796
- });
3797
- }
3798
- }
3799
- break;
3840
+ if (!lines[i].includes("@perf-critical")) continue;
3841
+ const fnName = this.resolveFunctionName(lines, i + 1);
3842
+ if (fnName) {
3843
+ entries.push({ file: relativePath, function: fnName, source: "annotation" });
3800
3844
  }
3801
3845
  }
3802
3846
  }
@@ -3951,14 +3995,19 @@ function detectFileStatus(part) {
3951
3995
  if (part.includes("rename from")) return "renamed";
3952
3996
  return "modified";
3953
3997
  }
3954
- function parseDiffPart(part) {
3998
+ function parseDiffHeader(part) {
3955
3999
  if (!part.trim()) return null;
3956
4000
  const headerMatch = /diff --git a\/(.+?) b\/(.+?)(?:\n|$)/.exec(part);
3957
4001
  if (!headerMatch || !headerMatch[2]) return null;
3958
- const additionRegex = /^\+(?!\+\+)/gm;
4002
+ return headerMatch[2];
4003
+ }
4004
+ function parseDiffPart(part) {
4005
+ const path34 = parseDiffHeader(part);
4006
+ if (!path34) return null;
4007
+ const additionRegex = /^\+(?!\+\+)/gm;
3959
4008
  const deletionRegex = /^-(?!--)/gm;
3960
4009
  return {
3961
- path: headerMatch[2],
4010
+ path: path34,
3962
4011
  status: detectFileStatus(part),
3963
4012
  additions: (part.match(additionRegex) || []).length,
3964
4013
  deletions: (part.match(deletionRegex) || []).length
@@ -3980,100 +4029,136 @@ function parseDiff(diff2) {
3980
4029
  });
3981
4030
  }
3982
4031
  }
3983
- async function analyzeDiff(changes, options, graphImpactData) {
3984
- if (!options?.enabled) {
3985
- return Ok([]);
4032
+ function checkForbiddenPatterns(diff2, forbiddenPatterns, nextId) {
4033
+ const items = [];
4034
+ if (!forbiddenPatterns) return items;
4035
+ for (const forbidden of forbiddenPatterns) {
4036
+ const pattern = typeof forbidden.pattern === "string" ? new RegExp(forbidden.pattern, "g") : forbidden.pattern;
4037
+ if (!pattern.test(diff2)) continue;
4038
+ items.push({
4039
+ id: nextId(),
4040
+ category: "diff",
4041
+ check: `Forbidden pattern: ${forbidden.pattern}`,
4042
+ passed: false,
4043
+ severity: forbidden.severity,
4044
+ details: forbidden.message,
4045
+ suggestion: `Remove occurrences of ${forbidden.pattern}`
4046
+ });
3986
4047
  }
4048
+ return items;
4049
+ }
4050
+ function checkMaxChangedFiles(files, maxChangedFiles, nextId) {
4051
+ if (!maxChangedFiles || files.length <= maxChangedFiles) return [];
4052
+ return [
4053
+ {
4054
+ id: nextId(),
4055
+ category: "diff",
4056
+ check: `PR size: ${files.length} files changed`,
4057
+ passed: false,
4058
+ severity: "warning",
4059
+ details: `This PR changes ${files.length} files, which exceeds the recommended maximum of ${maxChangedFiles}`,
4060
+ suggestion: "Consider breaking this into smaller PRs"
4061
+ }
4062
+ ];
4063
+ }
4064
+ function checkFileSizes(files, maxFileSize, nextId) {
3987
4065
  const items = [];
3988
- let itemId = 0;
3989
- if (options.forbiddenPatterns) {
3990
- for (const forbidden of options.forbiddenPatterns) {
3991
- const pattern = typeof forbidden.pattern === "string" ? new RegExp(forbidden.pattern, "g") : forbidden.pattern;
3992
- if (pattern.test(changes.diff)) {
3993
- items.push({
3994
- id: `diff-${++itemId}`,
3995
- category: "diff",
3996
- check: `Forbidden pattern: ${forbidden.pattern}`,
3997
- passed: false,
3998
- severity: forbidden.severity,
3999
- details: forbidden.message,
4000
- suggestion: `Remove occurrences of ${forbidden.pattern}`
4001
- });
4002
- }
4066
+ if (!maxFileSize) return items;
4067
+ for (const file of files) {
4068
+ const totalLines = file.additions + file.deletions;
4069
+ if (totalLines <= maxFileSize) continue;
4070
+ items.push({
4071
+ id: nextId(),
4072
+ category: "diff",
4073
+ check: `File size: ${file.path}`,
4074
+ passed: false,
4075
+ severity: "warning",
4076
+ details: `File has ${totalLines} lines changed, exceeding limit of ${maxFileSize}`,
4077
+ file: file.path,
4078
+ suggestion: "Consider splitting this file into smaller modules"
4079
+ });
4080
+ }
4081
+ return items;
4082
+ }
4083
+ function checkTestCoverageGraph(files, graphImpactData) {
4084
+ const items = [];
4085
+ for (const file of files) {
4086
+ if (file.status !== "added" || !file.path.endsWith(".ts") || file.path.includes(".test.")) {
4087
+ continue;
4003
4088
  }
4089
+ const hasGraphTest = graphImpactData.affectedTests.some((t) => t.coversFile === file.path);
4090
+ if (hasGraphTest) continue;
4091
+ items.push({
4092
+ id: `test-coverage-${file.path}`,
4093
+ category: "diff",
4094
+ check: "Test coverage (graph)",
4095
+ passed: false,
4096
+ severity: "warning",
4097
+ details: `New file ${file.path} has no test file linked in the graph`,
4098
+ file: file.path
4099
+ });
4004
4100
  }
4005
- if (options.maxChangedFiles && changes.files.length > options.maxChangedFiles) {
4101
+ return items;
4102
+ }
4103
+ function checkTestCoverageFilename(files, nextId) {
4104
+ const items = [];
4105
+ const addedSourceFiles = files.filter(
4106
+ (f) => f.status === "added" && f.path.endsWith(".ts") && !f.path.includes(".test.")
4107
+ );
4108
+ const testFiles = files.filter((f) => f.path.includes(".test."));
4109
+ for (const sourceFile of addedSourceFiles) {
4110
+ const expectedTestPath = sourceFile.path.replace(".ts", ".test.ts");
4111
+ const hasTest = testFiles.some(
4112
+ (t) => t.path.includes(expectedTestPath) || t.path.includes(sourceFile.path.replace(".ts", ""))
4113
+ );
4114
+ if (hasTest) continue;
4006
4115
  items.push({
4007
- id: `diff-${++itemId}`,
4116
+ id: nextId(),
4008
4117
  category: "diff",
4009
- check: `PR size: ${changes.files.length} files changed`,
4118
+ check: `Test coverage: ${sourceFile.path}`,
4010
4119
  passed: false,
4011
4120
  severity: "warning",
4012
- details: `This PR changes ${changes.files.length} files, which exceeds the recommended maximum of ${options.maxChangedFiles}`,
4013
- suggestion: "Consider breaking this into smaller PRs"
4121
+ details: "New source file added without corresponding test file",
4122
+ file: sourceFile.path,
4123
+ suggestion: `Add tests in ${expectedTestPath}`
4014
4124
  });
4015
4125
  }
4016
- if (options.maxFileSize) {
4017
- for (const file of changes.files) {
4018
- const totalLines = file.additions + file.deletions;
4019
- if (totalLines > options.maxFileSize) {
4020
- items.push({
4021
- id: `diff-${++itemId}`,
4022
- category: "diff",
4023
- check: `File size: ${file.path}`,
4024
- passed: false,
4025
- severity: "warning",
4026
- details: `File has ${totalLines} lines changed, exceeding limit of ${options.maxFileSize}`,
4027
- file: file.path,
4028
- suggestion: "Consider splitting this file into smaller modules"
4029
- });
4030
- }
4126
+ return items;
4127
+ }
4128
+ function checkDocCoverage2(files, graphImpactData) {
4129
+ const items = [];
4130
+ for (const file of files) {
4131
+ if (file.status !== "modified" || !file.path.endsWith(".ts") || file.path.includes(".test.")) {
4132
+ continue;
4031
4133
  }
4134
+ const hasDoc = graphImpactData.affectedDocs.some((d) => d.documentsFile === file.path);
4135
+ if (hasDoc) continue;
4136
+ items.push({
4137
+ id: `doc-coverage-${file.path}`,
4138
+ category: "diff",
4139
+ check: "Documentation coverage (graph)",
4140
+ passed: true,
4141
+ severity: "info",
4142
+ details: `Modified file ${file.path} has no documentation linked in the graph`,
4143
+ file: file.path
4144
+ });
4145
+ }
4146
+ return items;
4147
+ }
4148
+ async function analyzeDiff(changes, options, graphImpactData) {
4149
+ if (!options?.enabled) {
4150
+ return Ok([]);
4032
4151
  }
4152
+ let itemId = 0;
4153
+ const nextId = () => `diff-${++itemId}`;
4154
+ const items = [
4155
+ ...checkForbiddenPatterns(changes.diff, options.forbiddenPatterns, nextId),
4156
+ ...checkMaxChangedFiles(changes.files, options.maxChangedFiles, nextId),
4157
+ ...checkFileSizes(changes.files, options.maxFileSize, nextId)
4158
+ ];
4033
4159
  if (options.checkTestCoverage) {
4034
- if (graphImpactData) {
4035
- for (const file of changes.files) {
4036
- if (file.status === "added" && file.path.endsWith(".ts") && !file.path.includes(".test.")) {
4037
- const hasGraphTest = graphImpactData.affectedTests.some(
4038
- (t) => t.coversFile === file.path
4039
- );
4040
- if (!hasGraphTest) {
4041
- items.push({
4042
- id: `test-coverage-${file.path}`,
4043
- category: "diff",
4044
- check: "Test coverage (graph)",
4045
- passed: false,
4046
- severity: "warning",
4047
- details: `New file ${file.path} has no test file linked in the graph`,
4048
- file: file.path
4049
- });
4050
- }
4051
- }
4052
- }
4053
- } else {
4054
- const addedSourceFiles = changes.files.filter(
4055
- (f) => f.status === "added" && f.path.endsWith(".ts") && !f.path.includes(".test.")
4056
- );
4057
- const testFiles = changes.files.filter((f) => f.path.includes(".test."));
4058
- for (const sourceFile of addedSourceFiles) {
4059
- const expectedTestPath = sourceFile.path.replace(".ts", ".test.ts");
4060
- const hasTest = testFiles.some(
4061
- (t) => t.path.includes(expectedTestPath) || t.path.includes(sourceFile.path.replace(".ts", ""))
4062
- );
4063
- if (!hasTest) {
4064
- items.push({
4065
- id: `diff-${++itemId}`,
4066
- category: "diff",
4067
- check: `Test coverage: ${sourceFile.path}`,
4068
- passed: false,
4069
- severity: "warning",
4070
- details: "New source file added without corresponding test file",
4071
- file: sourceFile.path,
4072
- suggestion: `Add tests in ${expectedTestPath}`
4073
- });
4074
- }
4075
- }
4076
- }
4160
+ const coverageItems = graphImpactData ? checkTestCoverageGraph(changes.files, graphImpactData) : checkTestCoverageFilename(changes.files, nextId);
4161
+ items.push(...coverageItems);
4077
4162
  }
4078
4163
  if (graphImpactData && graphImpactData.impactScope > 20) {
4079
4164
  items.push({
@@ -4086,22 +4171,7 @@ async function analyzeDiff(changes, options, graphImpactData) {
4086
4171
  });
4087
4172
  }
4088
4173
  if (graphImpactData) {
4089
- for (const file of changes.files) {
4090
- if (file.status === "modified" && file.path.endsWith(".ts") && !file.path.includes(".test.")) {
4091
- const hasDoc = graphImpactData.affectedDocs.some((d) => d.documentsFile === file.path);
4092
- if (!hasDoc) {
4093
- items.push({
4094
- id: `doc-coverage-${file.path}`,
4095
- category: "diff",
4096
- check: "Documentation coverage (graph)",
4097
- passed: true,
4098
- severity: "info",
4099
- details: `Modified file ${file.path} has no documentation linked in the graph`,
4100
- file: file.path
4101
- });
4102
- }
4103
- }
4104
- }
4174
+ items.push(...checkDocCoverage2(changes.files, graphImpactData));
4105
4175
  }
4106
4176
  return Ok(items);
4107
4177
  }
@@ -4634,10 +4704,26 @@ function hasMatchingViolation(rule, violationsByCategory) {
4634
4704
  }
4635
4705
 
4636
4706
  // src/architecture/detect-stale.ts
4707
+ function evaluateStaleNode(node, now, cutoff) {
4708
+ const lastViolatedAt = node.lastViolatedAt ?? null;
4709
+ const createdAt = node.createdAt;
4710
+ const comparisonTimestamp = lastViolatedAt ?? createdAt;
4711
+ if (!comparisonTimestamp) return null;
4712
+ const timestampMs = new Date(comparisonTimestamp).getTime();
4713
+ if (timestampMs >= cutoff) return null;
4714
+ const daysSince = Math.floor((now - timestampMs) / (24 * 60 * 60 * 1e3));
4715
+ return {
4716
+ id: node.id,
4717
+ category: node.category,
4718
+ description: node.name ?? "",
4719
+ scope: node.scope ?? "project",
4720
+ lastViolatedAt,
4721
+ daysSinceLastViolation: daysSince
4722
+ };
4723
+ }
4637
4724
  function detectStaleConstraints(store, windowDays = 30, category) {
4638
4725
  const now = Date.now();
4639
- const windowMs = windowDays * 24 * 60 * 60 * 1e3;
4640
- const cutoff = now - windowMs;
4726
+ const cutoff = now - windowDays * 24 * 60 * 60 * 1e3;
4641
4727
  let constraints = store.findNodes({ type: "constraint" });
4642
4728
  if (category) {
4643
4729
  constraints = constraints.filter((n) => n.category === category);
@@ -4645,28 +4731,23 @@ function detectStaleConstraints(store, windowDays = 30, category) {
4645
4731
  const totalConstraints = constraints.length;
4646
4732
  const staleConstraints = [];
4647
4733
  for (const node of constraints) {
4648
- const lastViolatedAt = node.lastViolatedAt ?? null;
4649
- const createdAt = node.createdAt;
4650
- const comparisonTimestamp = lastViolatedAt ?? createdAt;
4651
- if (!comparisonTimestamp) continue;
4652
- const timestampMs = new Date(comparisonTimestamp).getTime();
4653
- if (timestampMs < cutoff) {
4654
- const daysSince = Math.floor((now - timestampMs) / (24 * 60 * 60 * 1e3));
4655
- staleConstraints.push({
4656
- id: node.id,
4657
- category: node.category,
4658
- description: node.name ?? "",
4659
- scope: node.scope ?? "project",
4660
- lastViolatedAt,
4661
- daysSinceLastViolation: daysSince
4662
- });
4663
- }
4734
+ const entry = evaluateStaleNode(node, now, cutoff);
4735
+ if (entry) staleConstraints.push(entry);
4664
4736
  }
4665
4737
  staleConstraints.sort((a, b) => b.daysSinceLastViolation - a.daysSinceLastViolation);
4666
4738
  return { staleConstraints, totalConstraints, windowDays };
4667
4739
  }
4668
4740
 
4669
4741
  // src/architecture/config.ts
4742
+ function mergeThresholdCategory(projectValue2, moduleValue) {
4743
+ if (projectValue2 !== void 0 && typeof projectValue2 === "object" && !Array.isArray(projectValue2) && typeof moduleValue === "object" && !Array.isArray(moduleValue)) {
4744
+ return {
4745
+ ...projectValue2,
4746
+ ...moduleValue
4747
+ };
4748
+ }
4749
+ return moduleValue;
4750
+ }
4670
4751
  function resolveThresholds(scope, config) {
4671
4752
  const projectThresholds = {};
4672
4753
  for (const [key, val] of Object.entries(config.thresholds)) {
@@ -4682,14 +4763,7 @@ function resolveThresholds(scope, config) {
4682
4763
  const merged = { ...projectThresholds };
4683
4764
  for (const [category, moduleValue] of Object.entries(moduleOverrides)) {
4684
4765
  const projectValue2 = projectThresholds[category];
4685
- if (projectValue2 !== void 0 && typeof projectValue2 === "object" && !Array.isArray(projectValue2) && typeof moduleValue === "object" && !Array.isArray(moduleValue)) {
4686
- merged[category] = {
4687
- ...projectValue2,
4688
- ...moduleValue
4689
- };
4690
- } else {
4691
- merged[category] = moduleValue;
4692
- }
4766
+ merged[category] = mergeThresholdCategory(projectValue2, moduleValue);
4693
4767
  }
4694
4768
  return merged;
4695
4769
  }
@@ -5313,18 +5387,10 @@ var PredictionEngine = class {
5313
5387
  */
5314
5388
  predict(options) {
5315
5389
  const opts = this.resolveOptions(options);
5316
- const timeline = this.timelineManager.load();
5317
- const snapshots = timeline.snapshots;
5318
- if (snapshots.length < 3) {
5319
- throw new Error(
5320
- `PredictionEngine requires at least 3 snapshots, got ${snapshots.length}. Run "harness snapshot" to capture more data points.`
5321
- );
5322
- }
5390
+ const snapshots = this.loadValidatedSnapshots();
5323
5391
  const thresholds = this.resolveThresholds(opts);
5324
5392
  const categoriesToProcess = opts.categories ?? [...ALL_CATEGORIES2];
5325
- const firstDate = new Date(snapshots[0].capturedAt).getTime();
5326
- const lastSnapshot = snapshots[snapshots.length - 1];
5327
- const currentT = (new Date(lastSnapshot.capturedAt).getTime() - firstDate) / (7 * 24 * 60 * 60 * 1e3);
5393
+ const { firstDate, lastSnapshot, currentT } = this.computeTimeOffsets(snapshots);
5328
5394
  const baselines = this.computeBaselines(
5329
5395
  categoriesToProcess,
5330
5396
  thresholds,
@@ -5335,27 +5401,32 @@ var PredictionEngine = class {
5335
5401
  );
5336
5402
  const specImpacts = this.computeSpecImpacts(opts);
5337
5403
  const categories = this.computeAdjustedForecasts(baselines, thresholds, specImpacts, currentT);
5338
- const warnings = this.generateWarnings(
5339
- categories,
5340
- opts.horizon
5341
- );
5342
- const stabilityForecast = this.computeStabilityForecast(
5343
- categories,
5344
- thresholds,
5345
- snapshots
5346
- );
5404
+ const adjustedCategories = categories;
5347
5405
  return {
5348
5406
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
5349
5407
  snapshotsUsed: snapshots.length,
5350
- timelineRange: {
5351
- from: snapshots[0].capturedAt,
5352
- to: lastSnapshot.capturedAt
5353
- },
5354
- stabilityForecast,
5355
- categories,
5356
- warnings
5408
+ timelineRange: { from: snapshots[0].capturedAt, to: lastSnapshot.capturedAt },
5409
+ stabilityForecast: this.computeStabilityForecast(adjustedCategories, thresholds, snapshots),
5410
+ categories: adjustedCategories,
5411
+ warnings: this.generateWarnings(adjustedCategories, opts.horizon)
5357
5412
  };
5358
5413
  }
5414
+ loadValidatedSnapshots() {
5415
+ const timeline = this.timelineManager.load();
5416
+ const snapshots = timeline.snapshots;
5417
+ if (snapshots.length < 3) {
5418
+ throw new Error(
5419
+ `PredictionEngine requires at least 3 snapshots, got ${snapshots.length}. Run "harness snapshot" to capture more data points.`
5420
+ );
5421
+ }
5422
+ return snapshots;
5423
+ }
5424
+ computeTimeOffsets(snapshots) {
5425
+ const firstDate = new Date(snapshots[0].capturedAt).getTime();
5426
+ const lastSnapshot = snapshots[snapshots.length - 1];
5427
+ const currentT = (new Date(lastSnapshot.capturedAt).getTime() - firstDate) / (7 * 24 * 60 * 60 * 1e3);
5428
+ return { firstDate, lastSnapshot, currentT };
5429
+ }
5359
5430
  // --- Private helpers ---
5360
5431
  resolveOptions(options) {
5361
5432
  return {
@@ -5522,31 +5593,40 @@ var PredictionEngine = class {
5522
5593
  for (const category of ALL_CATEGORIES2) {
5523
5594
  const af = categories[category];
5524
5595
  if (!af) continue;
5525
- const forecast = af.adjusted;
5526
- const crossing = forecast.thresholdCrossingWeeks;
5527
- if (crossing === null || crossing <= 0) continue;
5528
- let severity = null;
5529
- if (crossing <= criticalWindow && (forecast.confidence === "high" || forecast.confidence === "medium")) {
5530
- severity = "critical";
5531
- } else if (crossing <= warningWindow && (forecast.confidence === "high" || forecast.confidence === "medium")) {
5532
- severity = "warning";
5533
- } else if (crossing <= horizon) {
5534
- severity = "info";
5535
- }
5536
- if (severity) {
5537
- const contributingNames = af.contributingFeatures.map((f) => f.name);
5538
- warnings.push({
5539
- severity,
5540
- category,
5541
- message: `${category} projected to exceed threshold (~${crossing}w, ${forecast.confidence} confidence)`,
5542
- weeksUntil: crossing,
5543
- confidence: forecast.confidence,
5544
- contributingFeatures: contributingNames
5545
- });
5546
- }
5596
+ const warning = this.buildCategoryWarning(
5597
+ category,
5598
+ af,
5599
+ criticalWindow,
5600
+ warningWindow,
5601
+ horizon
5602
+ );
5603
+ if (warning) warnings.push(warning);
5547
5604
  }
5548
5605
  return warnings;
5549
5606
  }
5607
+ buildCategoryWarning(category, af, criticalWindow, warningWindow, horizon) {
5608
+ const forecast = af.adjusted;
5609
+ const crossing = forecast.thresholdCrossingWeeks;
5610
+ if (crossing === null || crossing <= 0) return null;
5611
+ const isHighConfidence = forecast.confidence === "high" || forecast.confidence === "medium";
5612
+ let severity = null;
5613
+ if (crossing <= criticalWindow && isHighConfidence) {
5614
+ severity = "critical";
5615
+ } else if (crossing <= warningWindow && isHighConfidence) {
5616
+ severity = "warning";
5617
+ } else if (crossing <= horizon) {
5618
+ severity = "info";
5619
+ }
5620
+ if (!severity) return null;
5621
+ return {
5622
+ severity,
5623
+ category,
5624
+ message: `${category} projected to exceed threshold (~${crossing}w, ${forecast.confidence} confidence)`,
5625
+ weeksUntil: crossing,
5626
+ confidence: forecast.confidence,
5627
+ contributingFeatures: af.contributingFeatures.map((f) => f.name)
5628
+ };
5629
+ }
5550
5630
  /**
5551
5631
  * Compute composite stability forecast by projecting per-category values
5552
5632
  * forward and computing stability scores at each horizon.
@@ -5615,14 +5695,9 @@ var PredictionEngine = class {
5615
5695
  const raw = fs5.readFileSync(roadmapPath, "utf-8");
5616
5696
  const parseResult = parseRoadmap(raw);
5617
5697
  if (!parseResult.ok) return null;
5618
- const features = [];
5619
- for (const milestone of parseResult.value.milestones) {
5620
- for (const feature of milestone.features) {
5621
- if (feature.status === "planned" || feature.status === "in-progress") {
5622
- features.push({ name: feature.name, spec: feature.spec });
5623
- }
5624
- }
5625
- }
5698
+ const features = parseResult.value.milestones.flatMap(
5699
+ (m) => m.features.filter((f) => f.status === "planned" || f.status === "in-progress").map((f) => ({ name: f.name, spec: f.spec }))
5700
+ );
5626
5701
  if (features.length === 0) return null;
5627
5702
  return this.estimator.estimateAll(features);
5628
5703
  } catch {
@@ -6273,7 +6348,36 @@ async function saveState(projectPath, state, stream, session) {
6273
6348
  // src/state/learnings-content.ts
6274
6349
  import * as fs11 from "fs";
6275
6350
  import * as path8 from "path";
6276
- import * as crypto from "crypto";
6351
+ import * as crypto2 from "crypto";
6352
+
6353
+ // src/compaction/envelope.ts
6354
+ function estimateTokens(content) {
6355
+ return Math.ceil(content.length / 4);
6356
+ }
6357
+ function serializeEnvelope(envelope) {
6358
+ const { meta, sections } = envelope;
6359
+ const strategyLabel = meta.strategy.length > 0 ? meta.strategy.join("+") : "none";
6360
+ let cacheLabel = "";
6361
+ if (meta.cached) {
6362
+ if (meta.cacheReadTokens != null && meta.cacheInputTokens != null && meta.cacheInputTokens > 0) {
6363
+ const hitPct = Math.round(meta.cacheReadTokens / meta.cacheInputTokens * 100);
6364
+ const readFormatted = meta.cacheReadTokens >= 1e3 ? (meta.cacheReadTokens / 1e3).toFixed(1) + "K" : String(meta.cacheReadTokens);
6365
+ cacheLabel = ` [cached | cache: ${readFormatted} read, ${hitPct}% hit]`;
6366
+ } else {
6367
+ cacheLabel = " [cached]";
6368
+ }
6369
+ }
6370
+ const header = `<!-- packed: ${strategyLabel} | ${meta.originalTokenEstimate}\u2192${meta.compactedTokenEstimate} tokens (-${meta.reductionPct}%)${cacheLabel} -->`;
6371
+ if (sections.length === 0) {
6372
+ return header;
6373
+ }
6374
+ const body = sections.map((section) => `### [${section.source}]
6375
+ ${section.content}`).join("\n\n");
6376
+ return `${header}
6377
+ ${body}`;
6378
+ }
6379
+
6380
+ // src/state/learnings-content.ts
6277
6381
  function parseFrontmatter2(line) {
6278
6382
  const match = line.match(/^<!--\s+hash:([a-f0-9]+)(?:\s+tags:([^\s]+))?\s+-->/);
6279
6383
  if (!match) return null;
@@ -6301,7 +6405,7 @@ function extractIndexEntry(entry) {
6301
6405
  };
6302
6406
  }
6303
6407
  function computeEntryHash(text) {
6304
- return crypto.createHash("sha256").update(text).digest("hex").slice(0, 8);
6408
+ return crypto2.createHash("sha256").update(text).digest("hex").slice(0, 8);
6305
6409
  }
6306
6410
  function normalizeLearningContent(text) {
6307
6411
  let normalized = text;
@@ -6316,7 +6420,7 @@ function normalizeLearningContent(text) {
6316
6420
  return normalized;
6317
6421
  }
6318
6422
  function computeContentHash(text) {
6319
- return crypto.createHash("sha256").update(text).digest("hex").slice(0, 16);
6423
+ return crypto2.createHash("sha256").update(text).digest("hex").slice(0, 16);
6320
6424
  }
6321
6425
  function loadContentHashes(stateDir) {
6322
6426
  const hashesPath = path8.join(stateDir, CONTENT_HASHES_FILE);
@@ -6377,9 +6481,6 @@ function analyzeLearningPatterns(entries) {
6377
6481
  }
6378
6482
  return patterns.sort((a, b) => b.count - a.count);
6379
6483
  }
6380
- function estimateTokens(text) {
6381
- return Math.ceil(text.length / 4);
6382
- }
6383
6484
  function scoreRelevance(entry, intent) {
6384
6485
  if (!intent || intent.trim() === "") return 0;
6385
6486
  const intentWords = intent.toLowerCase().split(/\s+/).filter((w) => w.length > 2);
@@ -6456,7 +6557,7 @@ async function loadRelevantLearnings(projectPath, skillName, stream, session) {
6456
6557
  // src/state/learnings.ts
6457
6558
  import * as fs13 from "fs";
6458
6559
  import * as path10 from "path";
6459
- import * as crypto2 from "crypto";
6560
+ import * as crypto3 from "crypto";
6460
6561
  async function appendLearning(projectPath, learning, skillName, outcome, stream, session) {
6461
6562
  try {
6462
6563
  const dirResult = await getStateDir(projectPath, stream, session);
@@ -6493,7 +6594,7 @@ async function appendLearning(projectPath, learning, skillName, outcome, stream,
6493
6594
  } else {
6494
6595
  bulletLine = `- **${timestamp}:** ${learning}`;
6495
6596
  }
6496
- const hash = crypto2.createHash("sha256").update(bulletLine).digest("hex").slice(0, 8);
6597
+ const hash = crypto3.createHash("sha256").update(bulletLine).digest("hex").slice(0, 8);
6497
6598
  const tagsStr = fmTags.length > 0 ? ` tags:${fmTags.join(",")}` : "";
6498
6599
  const frontmatter = `<!-- hash:${hash}${tagsStr} -->`;
6499
6600
  const entry = `
@@ -7204,7 +7305,7 @@ async function updateSessionEntryStatus(projectPath, sessionSlug, section, entry
7204
7305
  }
7205
7306
  function generateEntryId() {
7206
7307
  const timestamp = Date.now().toString(36);
7207
- const random = Math.random().toString(36).substring(2, 8);
7308
+ const random = Buffer.from(crypto.getRandomValues(new Uint8Array(4))).toString("hex");
7208
7309
  return `${timestamp}-${random}`;
7209
7310
  }
7210
7311
 
@@ -7349,15 +7450,25 @@ async function loadEvents(projectPath, options) {
7349
7450
  );
7350
7451
  }
7351
7452
  }
7453
+ function phaseTransitionFields(data) {
7454
+ return {
7455
+ from: data?.from ?? "?",
7456
+ to: data?.to ?? "?",
7457
+ suffix: data?.taskCount ? ` (${data.taskCount} tasks)` : ""
7458
+ };
7459
+ }
7352
7460
  function formatPhaseTransition(event) {
7353
7461
  const data = event.data;
7354
- const suffix = data?.taskCount ? ` (${data.taskCount} tasks)` : "";
7355
- return `phase: ${data?.from ?? "?"} -> ${data?.to ?? "?"}${suffix}`;
7462
+ const { from, to, suffix } = phaseTransitionFields(data);
7463
+ return `phase: ${from} -> ${to}${suffix}`;
7464
+ }
7465
+ function formatGateChecks(checks) {
7466
+ return checks?.map((c) => `${c.name} ${c.passed ? "Y" : "N"}`).join(", ");
7356
7467
  }
7357
7468
  function formatGateResult(event) {
7358
7469
  const data = event.data;
7359
7470
  const status = data?.passed ? "passed" : "failed";
7360
- const checks = data?.checks?.map((c) => `${c.name} ${c.passed ? "Y" : "N"}`).join(", ");
7471
+ const checks = formatGateChecks(data?.checks);
7361
7472
  return checks ? `gate: ${status} (${checks})` : `gate: ${status}`;
7362
7473
  }
7363
7474
  function formatHandoffDetail(event) {
@@ -7633,34 +7744,36 @@ function resolveRuleSeverity(ruleId, defaultSeverity, overrides, strict) {
7633
7744
  // src/security/stack-detector.ts
7634
7745
  import * as fs22 from "fs";
7635
7746
  import * as path19 from "path";
7636
- function detectStack(projectRoot) {
7637
- const stacks = [];
7747
+ function nodeSubStacks(allDeps) {
7748
+ const found = [];
7749
+ if (allDeps.react || allDeps["react-dom"]) found.push("react");
7750
+ if (allDeps.express) found.push("express");
7751
+ if (allDeps.koa) found.push("koa");
7752
+ if (allDeps.fastify) found.push("fastify");
7753
+ if (allDeps.next) found.push("next");
7754
+ if (allDeps.vue) found.push("vue");
7755
+ if (allDeps.angular || allDeps["@angular/core"]) found.push("angular");
7756
+ return found;
7757
+ }
7758
+ function detectNodeStacks(projectRoot) {
7638
7759
  const pkgJsonPath = path19.join(projectRoot, "package.json");
7639
- if (fs22.existsSync(pkgJsonPath)) {
7640
- stacks.push("node");
7641
- try {
7642
- const pkgJson = JSON.parse(fs22.readFileSync(pkgJsonPath, "utf-8"));
7643
- const allDeps = {
7644
- ...pkgJson.dependencies,
7645
- ...pkgJson.devDependencies
7646
- };
7647
- if (allDeps.react || allDeps["react-dom"]) stacks.push("react");
7648
- if (allDeps.express) stacks.push("express");
7649
- if (allDeps.koa) stacks.push("koa");
7650
- if (allDeps.fastify) stacks.push("fastify");
7651
- if (allDeps.next) stacks.push("next");
7652
- if (allDeps.vue) stacks.push("vue");
7653
- if (allDeps.angular || allDeps["@angular/core"]) stacks.push("angular");
7654
- } catch {
7655
- }
7760
+ if (!fs22.existsSync(pkgJsonPath)) return [];
7761
+ const stacks = ["node"];
7762
+ try {
7763
+ const pkgJson = JSON.parse(fs22.readFileSync(pkgJsonPath, "utf-8"));
7764
+ const allDeps = { ...pkgJson.dependencies, ...pkgJson.devDependencies };
7765
+ stacks.push(...nodeSubStacks(allDeps));
7766
+ } catch {
7656
7767
  }
7657
- const goModPath = path19.join(projectRoot, "go.mod");
7658
- if (fs22.existsSync(goModPath)) {
7768
+ return stacks;
7769
+ }
7770
+ function detectStack(projectRoot) {
7771
+ const stacks = [...detectNodeStacks(projectRoot)];
7772
+ if (fs22.existsSync(path19.join(projectRoot, "go.mod"))) {
7659
7773
  stacks.push("go");
7660
7774
  }
7661
- const requirementsPath = path19.join(projectRoot, "requirements.txt");
7662
- const pyprojectPath = path19.join(projectRoot, "pyproject.toml");
7663
- if (fs22.existsSync(requirementsPath) || fs22.existsSync(pyprojectPath)) {
7775
+ const hasPython = fs22.existsSync(path19.join(projectRoot, "requirements.txt")) || fs22.existsSync(path19.join(projectRoot, "pyproject.toml"));
7776
+ if (hasPython) {
7664
7777
  stacks.push("python");
7665
7778
  }
7666
7779
  return stacks;
@@ -8504,6 +8617,56 @@ var SecurityScanner = class {
8504
8617
  });
8505
8618
  return this.scanLinesWithRules(lines, applicableRules, filePath, startLine);
8506
8619
  }
8620
+ /** Build a finding for a suppression comment that is missing its justification. */
8621
+ buildSuppressionFinding(rule, filePath, lineNumber, line) {
8622
+ return {
8623
+ ruleId: rule.id,
8624
+ ruleName: rule.name,
8625
+ category: rule.category,
8626
+ severity: this.config.strict ? "error" : "warning",
8627
+ confidence: "high",
8628
+ file: filePath,
8629
+ line: lineNumber,
8630
+ match: line.trim(),
8631
+ context: line,
8632
+ message: `Suppression of ${rule.id} requires justification: // harness-ignore ${rule.id}: <reason>`,
8633
+ remediation: `Add justification after colon: // harness-ignore ${rule.id}: false positive because ...`
8634
+ };
8635
+ }
8636
+ /** Check one line against a rule's patterns; return a finding or null. */
8637
+ matchRuleLine(rule, resolved, filePath, lineNumber, line) {
8638
+ for (const pattern of rule.patterns) {
8639
+ pattern.lastIndex = 0;
8640
+ if (!pattern.test(line)) continue;
8641
+ return {
8642
+ ruleId: rule.id,
8643
+ ruleName: rule.name,
8644
+ category: rule.category,
8645
+ severity: resolved,
8646
+ confidence: rule.confidence,
8647
+ file: filePath,
8648
+ line: lineNumber,
8649
+ match: line.trim(),
8650
+ context: line,
8651
+ message: rule.message,
8652
+ remediation: rule.remediation,
8653
+ ...rule.references ? { references: rule.references } : {}
8654
+ };
8655
+ }
8656
+ return null;
8657
+ }
8658
+ /** Scan a single line against a resolved rule; push any findings into the array. */
8659
+ scanLineForRule(rule, resolved, line, lineNumber, filePath, findings) {
8660
+ const suppressionMatch = parseHarnessIgnore(line, rule.id);
8661
+ if (suppressionMatch) {
8662
+ if (!suppressionMatch.justification) {
8663
+ findings.push(this.buildSuppressionFinding(rule, filePath, lineNumber, line));
8664
+ }
8665
+ return;
8666
+ }
8667
+ const finding = this.matchRuleLine(rule, resolved, filePath, lineNumber, line);
8668
+ if (finding) findings.push(finding);
8669
+ }
8507
8670
  /**
8508
8671
  * Core scanning loop shared by scanContent and scanContentForFile.
8509
8672
  * Evaluates each rule against each line, handling suppression (FP gate)
@@ -8520,46 +8683,7 @@ var SecurityScanner = class {
8520
8683
  );
8521
8684
  if (resolved === "off") continue;
8522
8685
  for (let i = 0; i < lines.length; i++) {
8523
- const line = lines[i] ?? "";
8524
- const suppressionMatch = parseHarnessIgnore(line, rule.id);
8525
- if (suppressionMatch) {
8526
- if (!suppressionMatch.justification) {
8527
- findings.push({
8528
- ruleId: rule.id,
8529
- ruleName: rule.name,
8530
- category: rule.category,
8531
- severity: this.config.strict ? "error" : "warning",
8532
- confidence: "high",
8533
- file: filePath,
8534
- line: startLine + i,
8535
- match: line.trim(),
8536
- context: line,
8537
- message: `Suppression of ${rule.id} requires justification: // harness-ignore ${rule.id}: <reason>`,
8538
- remediation: `Add justification after colon: // harness-ignore ${rule.id}: false positive because ...`
8539
- });
8540
- }
8541
- continue;
8542
- }
8543
- for (const pattern of rule.patterns) {
8544
- pattern.lastIndex = 0;
8545
- if (pattern.test(line)) {
8546
- findings.push({
8547
- ruleId: rule.id,
8548
- ruleName: rule.name,
8549
- category: rule.category,
8550
- severity: resolved,
8551
- confidence: rule.confidence,
8552
- file: filePath,
8553
- line: startLine + i,
8554
- match: line.trim(),
8555
- context: line,
8556
- message: rule.message,
8557
- remediation: rule.remediation,
8558
- ...rule.references ? { references: rule.references } : {}
8559
- });
8560
- break;
8561
- }
8562
- }
8686
+ this.scanLineForRule(rule, resolved, lines[i] ?? "", startLine + i, filePath, findings);
8563
8687
  }
8564
8688
  }
8565
8689
  return findings;
@@ -9979,37 +10103,30 @@ function extractConventionRules(bundle) {
9979
10103
  }
9980
10104
  return rules;
9981
10105
  }
9982
- function findMissingJsDoc(bundle) {
10106
+ var EXPORT_RE = /export\s+(?:async\s+)?(?:function|const|class|interface|type)\s+(\w+)/;
10107
+ function hasPrecedingJsDoc(lines, i) {
10108
+ for (let j = i - 1; j >= 0; j--) {
10109
+ const prev = lines[j].trim();
10110
+ if (prev === "") continue;
10111
+ return prev.endsWith("*/");
10112
+ }
10113
+ return false;
10114
+ }
10115
+ function scanFileForMissingJsDoc(filePath, lines) {
9983
10116
  const missing = [];
9984
- for (const cf of bundle.changedFiles) {
9985
- const lines = cf.content.split("\n");
9986
- for (let i = 0; i < lines.length; i++) {
9987
- const line = lines[i];
9988
- const exportMatch = line.match(
9989
- /export\s+(?:async\s+)?(?:function|const|class|interface|type)\s+(\w+)/
9990
- );
9991
- if (exportMatch) {
9992
- let hasJsDoc = false;
9993
- for (let j = i - 1; j >= 0; j--) {
9994
- const prev = lines[j].trim();
9995
- if (prev === "") continue;
9996
- if (prev.endsWith("*/")) {
9997
- hasJsDoc = true;
9998
- }
9999
- break;
10000
- }
10001
- if (!hasJsDoc) {
10002
- missing.push({
10003
- file: cf.path,
10004
- line: i + 1,
10005
- exportName: exportMatch[1]
10006
- });
10007
- }
10008
- }
10117
+ for (let i = 0; i < lines.length; i++) {
10118
+ const exportMatch = lines[i].match(EXPORT_RE);
10119
+ if (exportMatch && !hasPrecedingJsDoc(lines, i)) {
10120
+ missing.push({ file: filePath, line: i + 1, exportName: exportMatch[1] });
10009
10121
  }
10010
10122
  }
10011
10123
  return missing;
10012
10124
  }
10125
+ function findMissingJsDoc(bundle) {
10126
+ return bundle.changedFiles.flatMap(
10127
+ (cf) => scanFileForMissingJsDoc(cf.path, cf.content.split("\n"))
10128
+ );
10129
+ }
10013
10130
  function checkMissingJsDoc(bundle, rules) {
10014
10131
  const jsDocRule = rules.find((r) => r.text.toLowerCase().includes("jsdoc"));
10015
10132
  if (!jsDocRule) return [];
@@ -10074,29 +10191,27 @@ function checkChangeTypeSpecific(bundle) {
10074
10191
  return [];
10075
10192
  }
10076
10193
  }
10194
+ function checkFileResultTypeConvention(cf, bundle, rule) {
10195
+ const hasTryCatch = cf.content.includes("try {") || cf.content.includes("try{");
10196
+ const usesResult = cf.content.includes("Result<") || cf.content.includes("Result >") || cf.content.includes(": Result");
10197
+ if (!hasTryCatch || usesResult) return null;
10198
+ return {
10199
+ id: makeFindingId("compliance", cf.path, 1, "try-catch not Result"),
10200
+ file: cf.path,
10201
+ lineRange: [1, cf.lines],
10202
+ domain: "compliance",
10203
+ severity: "suggestion",
10204
+ title: "Fallible operation uses try/catch instead of Result type",
10205
+ rationale: `Convention requires using Result type for fallible operations (from ${rule.source}).`,
10206
+ suggestion: "Refactor error handling to use the Result type pattern.",
10207
+ evidence: [`changeType: ${bundle.changeType}`, `Convention rule: "${rule.text}"`],
10208
+ validatedBy: "heuristic"
10209
+ };
10210
+ }
10077
10211
  function checkResultTypeConvention(bundle, rules) {
10078
10212
  const resultTypeRule = rules.find((r) => r.text.toLowerCase().includes("result type"));
10079
10213
  if (!resultTypeRule) return [];
10080
- const findings = [];
10081
- for (const cf of bundle.changedFiles) {
10082
- const hasTryCatch = cf.content.includes("try {") || cf.content.includes("try{");
10083
- const usesResult = cf.content.includes("Result<") || cf.content.includes("Result >") || cf.content.includes(": Result");
10084
- if (hasTryCatch && !usesResult) {
10085
- findings.push({
10086
- id: makeFindingId("compliance", cf.path, 1, "try-catch not Result"),
10087
- file: cf.path,
10088
- lineRange: [1, cf.lines],
10089
- domain: "compliance",
10090
- severity: "suggestion",
10091
- title: "Fallible operation uses try/catch instead of Result type",
10092
- rationale: `Convention requires using Result type for fallible operations (from ${resultTypeRule.source}).`,
10093
- suggestion: "Refactor error handling to use the Result type pattern.",
10094
- evidence: [`changeType: ${bundle.changeType}`, `Convention rule: "${resultTypeRule.text}"`],
10095
- validatedBy: "heuristic"
10096
- });
10097
- }
10098
- }
10099
- return findings;
10214
+ return bundle.changedFiles.map((cf) => checkFileResultTypeConvention(cf, bundle, resultTypeRule)).filter((f) => f !== null);
10100
10215
  }
10101
10216
  function runComplianceAgent(bundle) {
10102
10217
  const rules = extractConventionRules(bundle);
@@ -10122,53 +10237,58 @@ var BUG_DETECTION_DESCRIPTOR = {
10122
10237
  "Test coverage \u2014 tests for happy path, error paths, and edge cases"
10123
10238
  ]
10124
10239
  };
10240
+ function hasPrecedingZeroCheck(lines, i) {
10241
+ const preceding = lines.slice(Math.max(0, i - 3), i).join("\n");
10242
+ return preceding.includes("=== 0") || preceding.includes("!== 0") || preceding.includes("== 0") || preceding.includes("!= 0");
10243
+ }
10125
10244
  function detectDivisionByZero(bundle) {
10126
10245
  const findings = [];
10127
10246
  for (const cf of bundle.changedFiles) {
10128
10247
  const lines = cf.content.split("\n");
10129
10248
  for (let i = 0; i < lines.length; i++) {
10130
10249
  const line = lines[i];
10131
- if (line.match(/[^=!<>]\s*\/\s*[a-zA-Z_]\w*/) && !line.includes("//")) {
10132
- const preceding = lines.slice(Math.max(0, i - 3), i).join("\n");
10133
- if (!preceding.includes("=== 0") && !preceding.includes("!== 0") && !preceding.includes("== 0") && !preceding.includes("!= 0")) {
10134
- findings.push({
10135
- id: makeFindingId("bug", cf.path, i + 1, "division by zero"),
10136
- file: cf.path,
10137
- lineRange: [i + 1, i + 1],
10138
- domain: "bug",
10139
- severity: "important",
10140
- title: "Potential division by zero without guard",
10141
- rationale: "Division operation found without a preceding zero check on the divisor. This can cause Infinity or NaN at runtime.",
10142
- suggestion: "Add a check for zero before dividing, or use a safe division utility.",
10143
- evidence: [`Line ${i + 1}: ${line.trim()}`],
10144
- validatedBy: "heuristic"
10145
- });
10146
- }
10147
- }
10250
+ if (!line.match(/[^=!<>]\s*\/\s*[a-zA-Z_]\w*/) || line.includes("//")) continue;
10251
+ if (hasPrecedingZeroCheck(lines, i)) continue;
10252
+ findings.push({
10253
+ id: makeFindingId("bug", cf.path, i + 1, "division by zero"),
10254
+ file: cf.path,
10255
+ lineRange: [i + 1, i + 1],
10256
+ domain: "bug",
10257
+ severity: "important",
10258
+ title: "Potential division by zero without guard",
10259
+ rationale: "Division operation found without a preceding zero check on the divisor. This can cause Infinity or NaN at runtime.",
10260
+ suggestion: "Add a check for zero before dividing, or use a safe division utility.",
10261
+ evidence: [`Line ${i + 1}: ${line.trim()}`],
10262
+ validatedBy: "heuristic"
10263
+ });
10148
10264
  }
10149
10265
  }
10150
10266
  return findings;
10151
10267
  }
10268
+ function isEmptyCatch(lines, i) {
10269
+ const line = lines[i];
10270
+ if (line.match(/catch\s*\([^)]*\)\s*\{\s*\}/)) return true;
10271
+ return line.match(/catch\s*\([^)]*\)\s*\{/) !== null && i + 1 < lines.length && lines[i + 1].trim() === "}";
10272
+ }
10152
10273
  function detectEmptyCatch(bundle) {
10153
10274
  const findings = [];
10154
10275
  for (const cf of bundle.changedFiles) {
10155
10276
  const lines = cf.content.split("\n");
10156
10277
  for (let i = 0; i < lines.length; i++) {
10278
+ if (!isEmptyCatch(lines, i)) continue;
10157
10279
  const line = lines[i];
10158
- if (line.match(/catch\s*\([^)]*\)\s*\{\s*\}/) || line.match(/catch\s*\([^)]*\)\s*\{/) && i + 1 < lines.length && lines[i + 1].trim() === "}") {
10159
- findings.push({
10160
- id: makeFindingId("bug", cf.path, i + 1, "empty catch block"),
10161
- file: cf.path,
10162
- lineRange: [i + 1, i + 2],
10163
- domain: "bug",
10164
- severity: "important",
10165
- title: "Empty catch block silently swallows error",
10166
- rationale: "Catching an error without handling, logging, or re-throwing it hides failures and makes debugging difficult.",
10167
- suggestion: "Log the error, re-throw it, or handle it explicitly. If intentionally ignoring, add a comment explaining why.",
10168
- evidence: [`Line ${i + 1}: ${line.trim()}`],
10169
- validatedBy: "heuristic"
10170
- });
10171
- }
10280
+ findings.push({
10281
+ id: makeFindingId("bug", cf.path, i + 1, "empty catch block"),
10282
+ file: cf.path,
10283
+ lineRange: [i + 1, i + 2],
10284
+ domain: "bug",
10285
+ severity: "important",
10286
+ title: "Empty catch block silently swallows error",
10287
+ rationale: "Catching an error without handling, logging, or re-throwing it hides failures and makes debugging difficult.",
10288
+ suggestion: "Log the error, re-throw it, or handle it explicitly. If intentionally ignoring, add a comment explaining why.",
10289
+ evidence: [`Line ${i + 1}: ${line.trim()}`],
10290
+ validatedBy: "heuristic"
10291
+ });
10172
10292
  }
10173
10293
  }
10174
10294
  return findings;
@@ -10226,70 +10346,116 @@ var SECRET_PATTERNS = [
10226
10346
  ];
10227
10347
  var SQL_CONCAT_PATTERN = /(?:SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER)\s+.*?\+\s*\w+|`[^`]*\$\{[^}]*\}[^`]*(?:SELECT|INSERT|UPDATE|DELETE|WHERE)/i;
10228
10348
  var SHELL_EXEC_PATTERN = /(?:exec|execSync|spawn|spawnSync)\s*\(\s*`[^`]*\$\{/;
10229
- function detectEvalUsage(bundle) {
10230
- const findings = [];
10231
- for (const cf of bundle.changedFiles) {
10232
- const lines = cf.content.split("\n");
10233
- for (let i = 0; i < lines.length; i++) {
10234
- const line = lines[i];
10235
- if (EVAL_PATTERN.test(line)) {
10236
- findings.push({
10237
- id: makeFindingId("security", cf.path, i + 1, "eval usage CWE-94"),
10238
- file: cf.path,
10239
- lineRange: [i + 1, i + 1],
10240
- domain: "security",
10241
- severity: "critical",
10242
- title: `Dangerous ${"eval"}() or new ${"Function"}() usage`,
10243
- rationale: `${"eval"}() and new ${"Function"}() execute arbitrary code. If user input reaches these calls, it enables Remote Code Execution (CWE-94).`,
10244
- suggestion: "Replace eval/Function with a safe alternative (JSON.parse for data, a sandboxed evaluator for expressions).",
10245
- evidence: [`Line ${i + 1}: ${line.trim()}`],
10246
- validatedBy: "heuristic",
10247
- cweId: "CWE-94",
10248
- owaspCategory: "A03:2021 Injection",
10249
- confidence: "high",
10250
- remediation: "Replace eval/Function with a safe alternative (JSON.parse for data, a sandboxed evaluator for expressions).",
10251
- references: [
10252
- "https://cwe.mitre.org/data/definitions/94.html",
10253
- "https://owasp.org/Top10/A03_2021-Injection/"
10254
- ]
10255
- });
10256
- }
10257
- }
10258
- }
10259
- return findings;
10260
- }
10261
- function detectHardcodedSecrets(bundle) {
10349
+ function makeEvalFinding(file, lineNum, line) {
10350
+ return {
10351
+ id: makeFindingId("security", file, lineNum, "eval usage CWE-94"),
10352
+ file,
10353
+ lineRange: [lineNum, lineNum],
10354
+ domain: "security",
10355
+ severity: "critical",
10356
+ title: `Dangerous ${"eval"}() or new ${"Function"}() usage`,
10357
+ rationale: `${"eval"}() and new ${"Function"}() execute arbitrary code. If user input reaches these calls, it enables Remote Code Execution (CWE-94).`,
10358
+ suggestion: "Replace eval/Function with a safe alternative (JSON.parse for data, a sandboxed evaluator for expressions).",
10359
+ evidence: [`Line ${lineNum}: ${line.trim()}`],
10360
+ validatedBy: "heuristic",
10361
+ cweId: "CWE-94",
10362
+ owaspCategory: "A03:2021 Injection",
10363
+ confidence: "high",
10364
+ remediation: "Replace eval/Function with a safe alternative (JSON.parse for data, a sandboxed evaluator for expressions).",
10365
+ references: [
10366
+ "https://cwe.mitre.org/data/definitions/94.html",
10367
+ "https://owasp.org/Top10/A03_2021-Injection/"
10368
+ ]
10369
+ };
10370
+ }
10371
+ function makeSecretFinding(file, lineNum) {
10372
+ return {
10373
+ id: makeFindingId("security", file, lineNum, "hardcoded secret CWE-798"),
10374
+ file,
10375
+ lineRange: [lineNum, lineNum],
10376
+ domain: "security",
10377
+ severity: "critical",
10378
+ title: "Hardcoded secret or API key detected",
10379
+ rationale: "Hardcoded secrets in source code can be extracted from version history even after removal. Use environment variables or a secrets manager (CWE-798).",
10380
+ suggestion: "Move the secret to an environment variable and access it via process.env.",
10381
+ evidence: [`Line ${lineNum}: [secret detected \u2014 value redacted]`],
10382
+ validatedBy: "heuristic",
10383
+ cweId: "CWE-798",
10384
+ owaspCategory: "A07:2021 Identification and Authentication Failures",
10385
+ confidence: "high",
10386
+ remediation: "Move the secret to an environment variable and access it via process.env.",
10387
+ references: [
10388
+ "https://cwe.mitre.org/data/definitions/798.html",
10389
+ "https://owasp.org/Top10/A07_2021-Identification_and_Authentication_Failures/"
10390
+ ]
10391
+ };
10392
+ }
10393
+ function makeSqlFinding(file, lineNum, line) {
10394
+ return {
10395
+ id: makeFindingId("security", file, lineNum, "SQL injection CWE-89"),
10396
+ file,
10397
+ lineRange: [lineNum, lineNum],
10398
+ domain: "security",
10399
+ severity: "critical",
10400
+ title: "Potential SQL injection via string concatenation",
10401
+ rationale: "Building SQL queries with string concatenation or template literals allows attackers to inject malicious SQL (CWE-89).",
10402
+ suggestion: "Use parameterized queries or a query builder (e.g., Knex, Prisma) instead of string concatenation.",
10403
+ evidence: [`Line ${lineNum}: ${line.trim()}`],
10404
+ validatedBy: "heuristic",
10405
+ cweId: "CWE-89",
10406
+ owaspCategory: "A03:2021 Injection",
10407
+ confidence: "high",
10408
+ remediation: "Use parameterized queries or a query builder (e.g., Knex, Prisma) instead of string concatenation.",
10409
+ references: [
10410
+ "https://cwe.mitre.org/data/definitions/89.html",
10411
+ "https://owasp.org/Top10/A03_2021-Injection/"
10412
+ ]
10413
+ };
10414
+ }
10415
+ function makeCommandFinding(file, lineNum, line) {
10416
+ return {
10417
+ id: makeFindingId("security", file, lineNum, "command injection CWE-78"),
10418
+ file,
10419
+ lineRange: [lineNum, lineNum],
10420
+ domain: "security",
10421
+ severity: "critical",
10422
+ title: "Potential command injection via shell exec with interpolation",
10423
+ rationale: "Using exec/spawn with template literal interpolation allows attackers to inject shell commands (CWE-78).",
10424
+ suggestion: "Use execFile or spawn with an arguments array instead of shell string interpolation.",
10425
+ evidence: [`Line ${lineNum}: ${line.trim()}`],
10426
+ validatedBy: "heuristic",
10427
+ cweId: "CWE-78",
10428
+ owaspCategory: "A03:2021 Injection",
10429
+ confidence: "high",
10430
+ remediation: "Use execFile or spawn with an arguments array instead of shell string interpolation.",
10431
+ references: [
10432
+ "https://cwe.mitre.org/data/definitions/78.html",
10433
+ "https://owasp.org/Top10/A03_2021-Injection/"
10434
+ ]
10435
+ };
10436
+ }
10437
+ function detectEvalUsage(bundle) {
10438
+ const findings = [];
10439
+ for (const cf of bundle.changedFiles) {
10440
+ const lines = cf.content.split("\n");
10441
+ for (let i = 0; i < lines.length; i++) {
10442
+ const line = lines[i];
10443
+ if (!EVAL_PATTERN.test(line)) continue;
10444
+ findings.push(makeEvalFinding(cf.path, i + 1, line));
10445
+ }
10446
+ }
10447
+ return findings;
10448
+ }
10449
+ function detectHardcodedSecrets(bundle) {
10262
10450
  const findings = [];
10263
10451
  for (const cf of bundle.changedFiles) {
10264
10452
  const lines = cf.content.split("\n");
10265
10453
  for (let i = 0; i < lines.length; i++) {
10266
10454
  const line = lines[i];
10267
10455
  const codePart = line.includes("//") ? line.slice(0, line.indexOf("//")) : line;
10268
- for (const pattern of SECRET_PATTERNS) {
10269
- if (pattern.test(codePart)) {
10270
- findings.push({
10271
- id: makeFindingId("security", cf.path, i + 1, "hardcoded secret CWE-798"),
10272
- file: cf.path,
10273
- lineRange: [i + 1, i + 1],
10274
- domain: "security",
10275
- severity: "critical",
10276
- title: "Hardcoded secret or API key detected",
10277
- rationale: "Hardcoded secrets in source code can be extracted from version history even after removal. Use environment variables or a secrets manager (CWE-798).",
10278
- suggestion: "Move the secret to an environment variable and access it via process.env.",
10279
- evidence: [`Line ${i + 1}: [secret detected \u2014 value redacted]`],
10280
- validatedBy: "heuristic",
10281
- cweId: "CWE-798",
10282
- owaspCategory: "A07:2021 Identification and Authentication Failures",
10283
- confidence: "high",
10284
- remediation: "Move the secret to an environment variable and access it via process.env.",
10285
- references: [
10286
- "https://cwe.mitre.org/data/definitions/798.html",
10287
- "https://owasp.org/Top10/A07_2021-Identification_and_Authentication_Failures/"
10288
- ]
10289
- });
10290
- break;
10291
- }
10292
- }
10456
+ const matched = SECRET_PATTERNS.some((p) => p.test(codePart));
10457
+ if (!matched) continue;
10458
+ findings.push(makeSecretFinding(cf.path, i + 1));
10293
10459
  }
10294
10460
  }
10295
10461
  return findings;
@@ -10300,28 +10466,8 @@ function detectSqlInjection(bundle) {
10300
10466
  const lines = cf.content.split("\n");
10301
10467
  for (let i = 0; i < lines.length; i++) {
10302
10468
  const line = lines[i];
10303
- if (SQL_CONCAT_PATTERN.test(line)) {
10304
- findings.push({
10305
- id: makeFindingId("security", cf.path, i + 1, "SQL injection CWE-89"),
10306
- file: cf.path,
10307
- lineRange: [i + 1, i + 1],
10308
- domain: "security",
10309
- severity: "critical",
10310
- title: "Potential SQL injection via string concatenation",
10311
- rationale: "Building SQL queries with string concatenation or template literals allows attackers to inject malicious SQL (CWE-89).",
10312
- suggestion: "Use parameterized queries or a query builder (e.g., Knex, Prisma) instead of string concatenation.",
10313
- evidence: [`Line ${i + 1}: ${line.trim()}`],
10314
- validatedBy: "heuristic",
10315
- cweId: "CWE-89",
10316
- owaspCategory: "A03:2021 Injection",
10317
- confidence: "high",
10318
- remediation: "Use parameterized queries or a query builder (e.g., Knex, Prisma) instead of string concatenation.",
10319
- references: [
10320
- "https://cwe.mitre.org/data/definitions/89.html",
10321
- "https://owasp.org/Top10/A03_2021-Injection/"
10322
- ]
10323
- });
10324
- }
10469
+ if (!SQL_CONCAT_PATTERN.test(line)) continue;
10470
+ findings.push(makeSqlFinding(cf.path, i + 1, line));
10325
10471
  }
10326
10472
  }
10327
10473
  return findings;
@@ -10332,28 +10478,8 @@ function detectCommandInjection(bundle) {
10332
10478
  const lines = cf.content.split("\n");
10333
10479
  for (let i = 0; i < lines.length; i++) {
10334
10480
  const line = lines[i];
10335
- if (SHELL_EXEC_PATTERN.test(line)) {
10336
- findings.push({
10337
- id: makeFindingId("security", cf.path, i + 1, "command injection CWE-78"),
10338
- file: cf.path,
10339
- lineRange: [i + 1, i + 1],
10340
- domain: "security",
10341
- severity: "critical",
10342
- title: "Potential command injection via shell exec with interpolation",
10343
- rationale: "Using exec/spawn with template literal interpolation allows attackers to inject shell commands (CWE-78).",
10344
- suggestion: "Use execFile or spawn with an arguments array instead of shell string interpolation.",
10345
- evidence: [`Line ${i + 1}: ${line.trim()}`],
10346
- validatedBy: "heuristic",
10347
- cweId: "CWE-78",
10348
- owaspCategory: "A03:2021 Injection",
10349
- confidence: "high",
10350
- remediation: "Use execFile or spawn with an arguments array instead of shell string interpolation.",
10351
- references: [
10352
- "https://cwe.mitre.org/data/definitions/78.html",
10353
- "https://owasp.org/Top10/A03_2021-Injection/"
10354
- ]
10355
- });
10356
- }
10481
+ if (!SHELL_EXEC_PATTERN.test(line)) continue;
10482
+ findings.push(makeCommandFinding(cf.path, i + 1, line));
10357
10483
  }
10358
10484
  }
10359
10485
  return findings;
@@ -10386,10 +10512,15 @@ function isViolationLine(line) {
10386
10512
  const lower = line.toLowerCase();
10387
10513
  return lower.includes("violation") || lower.includes("layer");
10388
10514
  }
10389
- function createLayerViolationFinding(line, fallbackPath) {
10390
- const fileMatch = line.match(/(?:in\s+)?(\S+\.(?:ts|tsx|js|jsx))(?::(\d+))?/);
10515
+ var VIOLATION_FILE_RE = /(?:in\s+)?(\S+\.(?:ts|tsx|js|jsx))(?::(\d+))?/;
10516
+ function extractViolationLocation(line, fallbackPath) {
10517
+ const fileMatch = line.match(VIOLATION_FILE_RE);
10391
10518
  const file = fileMatch?.[1] ?? fallbackPath;
10392
10519
  const lineNum = fileMatch?.[2] ? parseInt(fileMatch[2], 10) : 1;
10520
+ return { file, lineNum };
10521
+ }
10522
+ function createLayerViolationFinding(line, fallbackPath) {
10523
+ const { file, lineNum } = extractViolationLocation(line, fallbackPath);
10393
10524
  return {
10394
10525
  id: makeFindingId("arch", file, lineNum, "layer violation"),
10395
10526
  file,
@@ -10562,6 +10693,26 @@ function normalizePath(filePath, projectRoot) {
10562
10693
  }
10563
10694
  return normalized;
10564
10695
  }
10696
+ function resolveImportPath2(currentFile, importPath) {
10697
+ const dir = path23.dirname(currentFile);
10698
+ let resolved = path23.join(dir, importPath).replace(/\\/g, "/");
10699
+ if (!resolved.match(/\.(ts|tsx|js|jsx)$/)) {
10700
+ resolved += ".ts";
10701
+ }
10702
+ return path23.normalize(resolved).replace(/\\/g, "/");
10703
+ }
10704
+ function enqueueImports(content, current, visited, queue, maxDepth) {
10705
+ const importRegex = /import\s+.*?from\s+['"]([^'"]+)['"]/g;
10706
+ let match;
10707
+ while ((match = importRegex.exec(content)) !== null) {
10708
+ const importPath = match[1];
10709
+ if (!importPath.startsWith(".")) continue;
10710
+ const resolved = resolveImportPath2(current.file, importPath);
10711
+ if (!visited.has(resolved) && current.depth + 1 <= maxDepth) {
10712
+ queue.push({ file: resolved, depth: current.depth + 1 });
10713
+ }
10714
+ }
10715
+ }
10565
10716
  function followImportChain(fromFile, fileContents, maxDepth = 2) {
10566
10717
  const visited = /* @__PURE__ */ new Set();
10567
10718
  const queue = [{ file: fromFile, depth: 0 }];
@@ -10571,82 +10722,63 @@ function followImportChain(fromFile, fileContents, maxDepth = 2) {
10571
10722
  visited.add(current.file);
10572
10723
  const content = fileContents.get(current.file);
10573
10724
  if (!content) continue;
10574
- const importRegex = /import\s+.*?from\s+['"]([^'"]+)['"]/g;
10575
- let match;
10576
- while ((match = importRegex.exec(content)) !== null) {
10577
- const importPath = match[1];
10578
- if (!importPath.startsWith(".")) continue;
10579
- const dir = path23.dirname(current.file);
10580
- let resolved = path23.join(dir, importPath).replace(/\\/g, "/");
10581
- if (!resolved.match(/\.(ts|tsx|js|jsx)$/)) {
10582
- resolved += ".ts";
10583
- }
10584
- resolved = path23.normalize(resolved).replace(/\\/g, "/");
10585
- if (!visited.has(resolved) && current.depth + 1 <= maxDepth) {
10586
- queue.push({ file: resolved, depth: current.depth + 1 });
10587
- }
10588
- }
10725
+ enqueueImports(content, current, visited, queue, maxDepth);
10589
10726
  }
10590
10727
  visited.delete(fromFile);
10591
10728
  return visited;
10592
10729
  }
10730
+ function isMechanicallyExcluded(finding, exclusionSet, projectRoot) {
10731
+ const normalizedFile = normalizePath(finding.file, projectRoot);
10732
+ if (exclusionSet.isExcluded(normalizedFile, finding.lineRange)) return true;
10733
+ if (exclusionSet.isExcluded(finding.file, finding.lineRange)) return true;
10734
+ const absoluteFile = path23.isAbsolute(finding.file) ? finding.file : path23.join(projectRoot, finding.file).replace(/\\/g, "/");
10735
+ return exclusionSet.isExcluded(absoluteFile, finding.lineRange);
10736
+ }
10737
+ async function validateWithGraph(crossFileRefs, graph) {
10738
+ try {
10739
+ for (const ref of crossFileRefs) {
10740
+ const reachable = await graph.isReachable(ref.from, ref.to);
10741
+ if (!reachable) return { result: "discard" };
10742
+ }
10743
+ return { result: "keep" };
10744
+ } catch {
10745
+ return { result: "fallback" };
10746
+ }
10747
+ }
10748
+ function validateWithHeuristic(finding, crossFileRefs, fileContents, projectRoot) {
10749
+ if (fileContents) {
10750
+ for (const ref of crossFileRefs) {
10751
+ const normalizedFrom = normalizePath(ref.from, projectRoot);
10752
+ const reachable = followImportChain(normalizedFrom, fileContents, 2);
10753
+ const normalizedTo = normalizePath(ref.to, projectRoot);
10754
+ if (reachable.has(normalizedTo)) {
10755
+ return { ...finding, validatedBy: "heuristic" };
10756
+ }
10757
+ }
10758
+ }
10759
+ return {
10760
+ ...finding,
10761
+ severity: DOWNGRADE_MAP[finding.severity],
10762
+ validatedBy: "heuristic"
10763
+ };
10764
+ }
10765
+ async function processFinding(finding, exclusionSet, graph, projectRoot, fileContents) {
10766
+ if (isMechanicallyExcluded(finding, exclusionSet, projectRoot)) return null;
10767
+ const crossFileRefs = extractCrossFileRefs(finding);
10768
+ if (crossFileRefs.length === 0) return { ...finding };
10769
+ if (graph) {
10770
+ const { result } = await validateWithGraph(crossFileRefs, graph);
10771
+ if (result === "keep") return { ...finding, validatedBy: "graph" };
10772
+ if (result === "discard") return null;
10773
+ }
10774
+ return validateWithHeuristic(finding, crossFileRefs, fileContents, projectRoot);
10775
+ }
10593
10776
  async function validateFindings(options) {
10594
10777
  const { findings, exclusionSet, graph, projectRoot, fileContents } = options;
10595
10778
  const validated = [];
10596
10779
  for (const finding of findings) {
10597
- const normalizedFile = normalizePath(finding.file, projectRoot);
10598
- if (exclusionSet.isExcluded(normalizedFile, finding.lineRange) || exclusionSet.isExcluded(finding.file, finding.lineRange)) {
10599
- continue;
10600
- }
10601
- const absoluteFile = path23.isAbsolute(finding.file) ? finding.file : path23.join(projectRoot, finding.file).replace(/\\/g, "/");
10602
- if (exclusionSet.isExcluded(absoluteFile, finding.lineRange)) {
10603
- continue;
10604
- }
10605
- const crossFileRefs = extractCrossFileRefs(finding);
10606
- if (crossFileRefs.length === 0) {
10607
- validated.push({ ...finding });
10608
- continue;
10609
- }
10610
- if (graph) {
10611
- try {
10612
- let allReachable = true;
10613
- for (const ref of crossFileRefs) {
10614
- const reachable = await graph.isReachable(ref.from, ref.to);
10615
- if (!reachable) {
10616
- allReachable = false;
10617
- break;
10618
- }
10619
- }
10620
- if (allReachable) {
10621
- validated.push({ ...finding, validatedBy: "graph" });
10622
- }
10623
- continue;
10624
- } catch {
10625
- }
10626
- }
10627
- {
10628
- let chainValidated = false;
10629
- if (fileContents) {
10630
- for (const ref of crossFileRefs) {
10631
- const normalizedFrom = normalizePath(ref.from, projectRoot);
10632
- const reachable = followImportChain(normalizedFrom, fileContents, 2);
10633
- const normalizedTo = normalizePath(ref.to, projectRoot);
10634
- if (reachable.has(normalizedTo)) {
10635
- chainValidated = true;
10636
- break;
10637
- }
10638
- }
10639
- }
10640
- if (chainValidated) {
10641
- validated.push({ ...finding, validatedBy: "heuristic" });
10642
- } else {
10643
- validated.push({
10644
- ...finding,
10645
- severity: DOWNGRADE_MAP[finding.severity],
10646
- validatedBy: "heuristic"
10647
- });
10648
- }
10649
- }
10780
+ const result = await processFinding(finding, exclusionSet, graph, projectRoot, fileContents);
10781
+ if (result !== null) validated.push(result);
10650
10782
  }
10651
10783
  return validated;
10652
10784
  }
@@ -11247,25 +11379,32 @@ function serializeRoadmap(roadmap) {
11247
11379
  function serializeMilestoneHeading(milestone) {
11248
11380
  return milestone.isBacklog ? "## Backlog" : `## ${milestone.name}`;
11249
11381
  }
11382
+ function orDash(value) {
11383
+ return value ?? EM_DASH2;
11384
+ }
11385
+ function listOrDash(items) {
11386
+ return items.length > 0 ? items.join(", ") : EM_DASH2;
11387
+ }
11388
+ function serializeExtendedLines(feature) {
11389
+ const hasExtended = feature.assignee !== null || feature.priority !== null || feature.externalId !== null;
11390
+ if (!hasExtended) return [];
11391
+ return [
11392
+ `- **Assignee:** ${orDash(feature.assignee)}`,
11393
+ `- **Priority:** ${orDash(feature.priority)}`,
11394
+ `- **External-ID:** ${orDash(feature.externalId)}`
11395
+ ];
11396
+ }
11250
11397
  function serializeFeature(feature) {
11251
- const spec = feature.spec ?? EM_DASH2;
11252
- const plans = feature.plans.length > 0 ? feature.plans.join(", ") : EM_DASH2;
11253
- const blockedBy = feature.blockedBy.length > 0 ? feature.blockedBy.join(", ") : EM_DASH2;
11254
11398
  const lines = [
11255
11399
  `### ${feature.name}`,
11256
11400
  "",
11257
11401
  `- **Status:** ${feature.status}`,
11258
- `- **Spec:** ${spec}`,
11402
+ `- **Spec:** ${orDash(feature.spec)}`,
11259
11403
  `- **Summary:** ${feature.summary}`,
11260
- `- **Blockers:** ${blockedBy}`,
11261
- `- **Plan:** ${plans}`
11404
+ `- **Blockers:** ${listOrDash(feature.blockedBy)}`,
11405
+ `- **Plan:** ${listOrDash(feature.plans)}`,
11406
+ ...serializeExtendedLines(feature)
11262
11407
  ];
11263
- const hasExtended = feature.assignee !== null || feature.priority !== null || feature.externalId !== null;
11264
- if (hasExtended) {
11265
- lines.push(`- **Assignee:** ${feature.assignee ?? EM_DASH2}`);
11266
- lines.push(`- **Priority:** ${feature.priority ?? EM_DASH2}`);
11267
- lines.push(`- **External-ID:** ${feature.externalId ?? EM_DASH2}`);
11268
- }
11269
11408
  return lines;
11270
11409
  }
11271
11410
  function serializeAssignmentHistory(records) {
@@ -11299,6 +11438,26 @@ function isRegression(from, to) {
11299
11438
  }
11300
11439
 
11301
11440
  // src/roadmap/sync.ts
11441
+ function collectAutopilotStatuses(autopilotPath, featurePlans, allTaskStatuses) {
11442
+ try {
11443
+ const raw = fs24.readFileSync(autopilotPath, "utf-8");
11444
+ const autopilot = JSON.parse(raw);
11445
+ if (!autopilot.phases) return;
11446
+ const linkedPhases = autopilot.phases.filter(
11447
+ (phase) => phase.planPath ? featurePlans.some((p) => p === phase.planPath || phase.planPath.endsWith(p)) : false
11448
+ );
11449
+ for (const phase of linkedPhases) {
11450
+ if (phase.status === "complete") {
11451
+ allTaskStatuses.push("complete");
11452
+ } else if (phase.status === "pending") {
11453
+ allTaskStatuses.push("pending");
11454
+ } else {
11455
+ allTaskStatuses.push("in_progress");
11456
+ }
11457
+ }
11458
+ } catch {
11459
+ }
11460
+ }
11302
11461
  function inferStatus(feature, projectPath, allFeatures) {
11303
11462
  if (feature.blockedBy.length > 0) {
11304
11463
  const blockerNotDone = feature.blockedBy.some((blockerName) => {
@@ -11334,26 +11493,7 @@ function inferStatus(feature, projectPath, allFeatures) {
11334
11493
  if (!entry.isDirectory()) continue;
11335
11494
  const autopilotPath = path24.join(sessionsDir, entry.name, "autopilot-state.json");
11336
11495
  if (!fs24.existsSync(autopilotPath)) continue;
11337
- try {
11338
- const raw = fs24.readFileSync(autopilotPath, "utf-8");
11339
- const autopilot = JSON.parse(raw);
11340
- if (!autopilot.phases) continue;
11341
- const linkedPhases = autopilot.phases.filter(
11342
- (phase) => phase.planPath ? feature.plans.some((p) => p === phase.planPath || phase.planPath.endsWith(p)) : false
11343
- );
11344
- if (linkedPhases.length > 0) {
11345
- for (const phase of linkedPhases) {
11346
- if (phase.status === "complete") {
11347
- allTaskStatuses.push("complete");
11348
- } else if (phase.status === "pending") {
11349
- allTaskStatuses.push("pending");
11350
- } else {
11351
- allTaskStatuses.push("in_progress");
11352
- }
11353
- }
11354
- }
11355
- } catch {
11356
- }
11496
+ collectAutopilotStatuses(autopilotPath, feature.plans, allTaskStatuses);
11357
11497
  }
11358
11498
  } catch {
11359
11499
  }
@@ -11670,23 +11810,36 @@ var GitHubIssuesSyncAdapter = class {
11670
11810
  return Err3(error instanceof Error ? error : new Error(String(error)));
11671
11811
  }
11672
11812
  }
11813
+ buildLabelsParam() {
11814
+ const filterLabels = this.config.labels ?? [];
11815
+ return filterLabels.length > 0 ? `&labels=${filterLabels.join(",")}` : "";
11816
+ }
11817
+ issueToTicketState(issue) {
11818
+ return {
11819
+ externalId: buildExternalId(this.owner, this.repo, issue.number),
11820
+ title: issue.title,
11821
+ status: issue.state,
11822
+ labels: issue.labels.map((l) => l.name),
11823
+ assignee: issue.assignee ? `@${issue.assignee.login}` : null
11824
+ };
11825
+ }
11826
+ async fetchIssuePage(page, labelsParam) {
11827
+ const perPage = 100;
11828
+ return fetchWithRetry(
11829
+ this.fetchFn,
11830
+ `${this.apiBase}/repos/${this.owner}/${this.repo}/issues?state=all&per_page=${perPage}&page=${page}${labelsParam}`,
11831
+ { method: "GET", headers: this.headers() },
11832
+ this.retryOpts
11833
+ );
11834
+ }
11673
11835
  async fetchAllTickets() {
11674
11836
  try {
11675
- const filterLabels = this.config.labels ?? [];
11676
- const labelsParam = filterLabels.length > 0 ? `&labels=${filterLabels.join(",")}` : "";
11837
+ const labelsParam = this.buildLabelsParam();
11677
11838
  const tickets = [];
11678
11839
  let page = 1;
11679
11840
  const perPage = 100;
11680
11841
  while (true) {
11681
- const response = await fetchWithRetry(
11682
- this.fetchFn,
11683
- `${this.apiBase}/repos/${this.owner}/${this.repo}/issues?state=all&per_page=${perPage}&page=${page}${labelsParam}`,
11684
- {
11685
- method: "GET",
11686
- headers: this.headers()
11687
- },
11688
- this.retryOpts
11689
- );
11842
+ const response = await this.fetchIssuePage(page, labelsParam);
11690
11843
  if (!response.ok) {
11691
11844
  const text = await response.text();
11692
11845
  return Err3(new Error(`GitHub API error ${response.status}: ${text}`));
@@ -11694,13 +11847,7 @@ var GitHubIssuesSyncAdapter = class {
11694
11847
  const data = await response.json();
11695
11848
  const issues = data.filter((d) => !d.pull_request);
11696
11849
  for (const issue of issues) {
11697
- tickets.push({
11698
- externalId: buildExternalId(this.owner, this.repo, issue.number),
11699
- title: issue.title,
11700
- status: issue.state,
11701
- labels: issue.labels.map((l) => l.name),
11702
- assignee: issue.assignee ? `@${issue.assignee.login}` : null
11703
- });
11850
+ tickets.push(this.issueToTicketState(issue));
11704
11851
  }
11705
11852
  if (data.length < perPage) break;
11706
11853
  page++;
@@ -11795,6 +11942,22 @@ async function syncToExternal(roadmap, adapter, config, prefetchedTickets) {
11795
11942
  }
11796
11943
  return result;
11797
11944
  }
11945
+ function applyTicketToFeature(ticketState, feature, config, forceSync, result) {
11946
+ if (ticketState.assignee !== feature.assignee) {
11947
+ result.assignmentChanges.push({
11948
+ feature: feature.name,
11949
+ from: feature.assignee,
11950
+ to: ticketState.assignee
11951
+ });
11952
+ feature.assignee = ticketState.assignee;
11953
+ }
11954
+ const resolvedStatus = resolveReverseStatus(ticketState.status, ticketState.labels, config);
11955
+ if (!resolvedStatus || resolvedStatus === feature.status) return;
11956
+ const newStatus = resolvedStatus;
11957
+ if (!forceSync && isRegression(feature.status, newStatus)) return;
11958
+ if (!forceSync && feature.status === "blocked" && newStatus === "planned") return;
11959
+ feature.status = newStatus;
11960
+ }
11798
11961
  async function syncFromExternal(roadmap, adapter, config, options, prefetchedTickets) {
11799
11962
  const result = emptySyncResult();
11800
11963
  const forceSync = options?.forceSync ?? false;
@@ -11821,25 +11984,7 @@ async function syncFromExternal(roadmap, adapter, config, options, prefetchedTic
11821
11984
  for (const ticketState of tickets) {
11822
11985
  const feature = featureByExternalId.get(ticketState.externalId);
11823
11986
  if (!feature) continue;
11824
- if (ticketState.assignee !== feature.assignee) {
11825
- result.assignmentChanges.push({
11826
- feature: feature.name,
11827
- from: feature.assignee,
11828
- to: ticketState.assignee
11829
- });
11830
- feature.assignee = ticketState.assignee;
11831
- }
11832
- const resolvedStatus = resolveReverseStatus(ticketState.status, ticketState.labels, config);
11833
- if (resolvedStatus && resolvedStatus !== feature.status) {
11834
- const newStatus = resolvedStatus;
11835
- if (!forceSync && isRegression(feature.status, newStatus)) {
11836
- continue;
11837
- }
11838
- if (!forceSync && feature.status === "blocked" && newStatus === "planned") {
11839
- continue;
11840
- }
11841
- feature.status = newStatus;
11842
- }
11987
+ applyTicketToFeature(ticketState, feature, config, forceSync, result);
11843
11988
  }
11844
11989
  return result;
11845
11990
  }
@@ -11887,6 +12032,24 @@ var PRIORITY_RANK = {
11887
12032
  var POSITION_WEIGHT = 0.5;
11888
12033
  var DEPENDENTS_WEIGHT = 0.3;
11889
12034
  var AFFINITY_WEIGHT = 0.2;
12035
+ function isEligibleCandidate(feature, allFeatureNames, doneFeatures) {
12036
+ if (feature.status !== "planned" && feature.status !== "backlog") return false;
12037
+ const isBlocked = feature.blockedBy.some((blocker) => {
12038
+ const key = blocker.toLowerCase();
12039
+ return allFeatureNames.has(key) && !doneFeatures.has(key);
12040
+ });
12041
+ return !isBlocked;
12042
+ }
12043
+ function computeAffinityScore(feature, milestoneName, milestoneMap, userCompletedFeatures) {
12044
+ if (userCompletedFeatures.size === 0) return 0;
12045
+ const completedBlocker = feature.blockedBy.some(
12046
+ (b) => userCompletedFeatures.has(b.toLowerCase())
12047
+ );
12048
+ if (completedBlocker) return 1;
12049
+ const siblings = milestoneMap.get(milestoneName) ?? [];
12050
+ const completedSibling = siblings.some((s) => userCompletedFeatures.has(s));
12051
+ return completedSibling ? 0.5 : 0;
12052
+ }
11890
12053
  function scoreRoadmapCandidates(roadmap, options) {
11891
12054
  const allFeatures = roadmap.milestones.flatMap((m) => m.features);
11892
12055
  const allFeatureNames = new Set(allFeatures.map((f) => f.name.toLowerCase()));
@@ -11925,33 +12088,18 @@ function scoreRoadmapCandidates(roadmap, options) {
11925
12088
  const candidates = [];
11926
12089
  let globalPosition = 0;
11927
12090
  for (const ms of roadmap.milestones) {
11928
- for (let featureIdx = 0; featureIdx < ms.features.length; featureIdx++) {
11929
- const feature = ms.features[featureIdx];
12091
+ for (const feature of ms.features) {
11930
12092
  globalPosition++;
11931
- if (feature.status !== "planned" && feature.status !== "backlog") continue;
11932
- const isBlocked = feature.blockedBy.some((blocker) => {
11933
- const key = blocker.toLowerCase();
11934
- return allFeatureNames.has(key) && !doneFeatures.has(key);
11935
- });
11936
- if (isBlocked) continue;
12093
+ if (!isEligibleCandidate(feature, allFeatureNames, doneFeatures)) continue;
11937
12094
  const positionScore = 1 - (globalPosition - 1) / totalPositions;
11938
12095
  const deps = dependentsCount.get(feature.name.toLowerCase()) ?? 0;
11939
12096
  const dependentsScore = deps / maxDependents;
11940
- let affinityScore = 0;
11941
- if (userCompletedFeatures.size > 0) {
11942
- const completedBlockers = feature.blockedBy.filter(
11943
- (b) => userCompletedFeatures.has(b.toLowerCase())
11944
- );
11945
- if (completedBlockers.length > 0) {
11946
- affinityScore = 1;
11947
- } else {
11948
- const siblings = milestoneMap.get(ms.name) ?? [];
11949
- const completedSiblings = siblings.filter((s) => userCompletedFeatures.has(s));
11950
- if (completedSiblings.length > 0) {
11951
- affinityScore = 0.5;
11952
- }
11953
- }
11954
- }
12097
+ const affinityScore = computeAffinityScore(
12098
+ feature,
12099
+ ms.name,
12100
+ milestoneMap,
12101
+ userCompletedFeatures
12102
+ );
11955
12103
  const weightedScore = POSITION_WEIGHT * positionScore + DEPENDENTS_WEIGHT * dependentsScore + AFFINITY_WEIGHT * affinityScore;
11956
12104
  const priorityTier = feature.priority ? PRIORITY_RANK[feature.priority] : null;
11957
12105
  candidates.push({
@@ -12292,9 +12440,9 @@ async function resolveWasmPath(grammarName) {
12292
12440
  const { createRequire } = await import("module");
12293
12441
  const require2 = createRequire(import.meta.url ?? __filename);
12294
12442
  const pkgPath = require2.resolve("tree-sitter-wasms/package.json");
12295
- const path31 = await import("path");
12296
- const pkgDir = path31.dirname(pkgPath);
12297
- return path31.join(pkgDir, "out", `${grammarName}.wasm`);
12443
+ const path34 = await import("path");
12444
+ const pkgDir = path34.dirname(pkgPath);
12445
+ return path34.join(pkgDir, "out", `${grammarName}.wasm`);
12298
12446
  }
12299
12447
  async function loadLanguage(lang) {
12300
12448
  const grammarName = GRAMMAR_MAP[lang];
@@ -13012,6 +13160,27 @@ function aggregateByDay(records) {
13012
13160
  // src/usage/jsonl-reader.ts
13013
13161
  import * as fs30 from "fs";
13014
13162
  import * as path29 from "path";
13163
+ function extractTokenUsage(entry, lineNumber) {
13164
+ const tokenUsage = entry.token_usage;
13165
+ if (!tokenUsage || typeof tokenUsage !== "object") {
13166
+ console.warn(
13167
+ `[harness usage] Skipping malformed JSONL line ${lineNumber}: missing token_usage`
13168
+ );
13169
+ return null;
13170
+ }
13171
+ return tokenUsage;
13172
+ }
13173
+ function applyOptionalFields2(record, entry) {
13174
+ if (entry.cache_creation_tokens != null) {
13175
+ record.cacheCreationTokens = entry.cache_creation_tokens;
13176
+ }
13177
+ if (entry.cache_read_tokens != null) {
13178
+ record.cacheReadTokens = entry.cache_read_tokens;
13179
+ }
13180
+ if (entry.model != null) {
13181
+ record.model = entry.model;
13182
+ }
13183
+ }
13015
13184
  function parseLine(line, lineNumber) {
13016
13185
  let entry;
13017
13186
  try {
@@ -13020,13 +13189,8 @@ function parseLine(line, lineNumber) {
13020
13189
  console.warn(`[harness usage] Skipping malformed JSONL line ${lineNumber}`);
13021
13190
  return null;
13022
13191
  }
13023
- const tokenUsage = entry.token_usage;
13024
- if (!tokenUsage || typeof tokenUsage !== "object") {
13025
- console.warn(
13026
- `[harness usage] Skipping malformed JSONL line ${lineNumber}: missing token_usage`
13027
- );
13028
- return null;
13029
- }
13192
+ const tokenUsage = extractTokenUsage(entry, lineNumber);
13193
+ if (!tokenUsage) return null;
13030
13194
  const inputTokens = tokenUsage.input_tokens ?? 0;
13031
13195
  const outputTokens = tokenUsage.output_tokens ?? 0;
13032
13196
  const record = {
@@ -13038,15 +13202,7 @@ function parseLine(line, lineNumber) {
13038
13202
  totalTokens: inputTokens + outputTokens
13039
13203
  }
13040
13204
  };
13041
- if (entry.cache_creation_tokens != null) {
13042
- record.cacheCreationTokens = entry.cache_creation_tokens;
13043
- }
13044
- if (entry.cache_read_tokens != null) {
13045
- record.cacheReadTokens = entry.cache_read_tokens;
13046
- }
13047
- if (entry.model != null) {
13048
- record.model = entry.model;
13049
- }
13205
+ applyOptionalFields2(record, entry);
13050
13206
  return record;
13051
13207
  }
13052
13208
  function readCostRecords(projectRoot) {
@@ -13081,6 +13237,14 @@ function extractUsage(entry) {
13081
13237
  const usage = message.usage;
13082
13238
  return usage && typeof usage === "object" && !Array.isArray(usage) ? usage : null;
13083
13239
  }
13240
+ function applyOptionalCCFields(record, message, usage) {
13241
+ const model = message.model;
13242
+ if (model) record.model = model;
13243
+ const cacheCreate = usage.cache_creation_input_tokens;
13244
+ const cacheRead = usage.cache_read_input_tokens;
13245
+ if (typeof cacheCreate === "number" && cacheCreate > 0) record.cacheCreationTokens = cacheCreate;
13246
+ if (typeof cacheRead === "number" && cacheRead > 0) record.cacheReadTokens = cacheRead;
13247
+ }
13084
13248
  function buildRecord(entry, usage) {
13085
13249
  const inputTokens = Number(usage.input_tokens) || 0;
13086
13250
  const outputTokens = Number(usage.output_tokens) || 0;
@@ -13091,12 +13255,7 @@ function buildRecord(entry, usage) {
13091
13255
  tokens: { inputTokens, outputTokens, totalTokens: inputTokens + outputTokens },
13092
13256
  _source: "claude-code"
13093
13257
  };
13094
- const model = message.model;
13095
- if (model) record.model = model;
13096
- const cacheCreate = usage.cache_creation_input_tokens;
13097
- const cacheRead = usage.cache_read_input_tokens;
13098
- if (typeof cacheCreate === "number" && cacheCreate > 0) record.cacheCreationTokens = cacheCreate;
13099
- if (typeof cacheRead === "number" && cacheRead > 0) record.cacheReadTokens = cacheRead;
13258
+ applyOptionalCCFields(record, message, usage);
13100
13259
  return record;
13101
13260
  }
13102
13261
  function parseCCLine(line, filePath, lineNumber) {
@@ -13163,13 +13322,457 @@ function parseCCRecords() {
13163
13322
  return records;
13164
13323
  }
13165
13324
 
13166
- // src/index.ts
13167
- var VERSION = "0.15.0";
13325
+ // src/adoption/reader.ts
13326
+ import * as fs32 from "fs";
13327
+ import * as path31 from "path";
13328
+ function parseLine2(line, lineNumber) {
13329
+ try {
13330
+ const parsed = JSON.parse(line);
13331
+ if (typeof parsed.skill !== "string" || typeof parsed.startedAt !== "string" || typeof parsed.duration !== "number" || typeof parsed.outcome !== "string" || !Array.isArray(parsed.phasesReached)) {
13332
+ process.stderr.write(
13333
+ `[harness adoption] Skipping malformed JSONL line ${lineNumber}: missing required fields
13334
+ `
13335
+ );
13336
+ return null;
13337
+ }
13338
+ return parsed;
13339
+ } catch {
13340
+ process.stderr.write(`[harness adoption] Skipping malformed JSONL line ${lineNumber}
13341
+ `);
13342
+ return null;
13343
+ }
13344
+ }
13345
+ function readAdoptionRecords(projectRoot) {
13346
+ const adoptionFile = path31.join(projectRoot, ".harness", "metrics", "adoption.jsonl");
13347
+ let raw;
13348
+ try {
13349
+ raw = fs32.readFileSync(adoptionFile, "utf-8");
13350
+ } catch {
13351
+ return [];
13352
+ }
13353
+ const records = [];
13354
+ const lines = raw.split("\n");
13355
+ for (let i = 0; i < lines.length; i++) {
13356
+ const line = lines[i]?.trim();
13357
+ if (!line) continue;
13358
+ const record = parseLine2(line, i + 1);
13359
+ if (record) {
13360
+ records.push(record);
13361
+ }
13362
+ }
13363
+ return records;
13364
+ }
13365
+
13366
+ // src/adoption/aggregator.ts
13367
+ function aggregateBySkill(records) {
13368
+ if (records.length === 0) return [];
13369
+ const skillMap = /* @__PURE__ */ new Map();
13370
+ for (const record of records) {
13371
+ if (!skillMap.has(record.skill)) {
13372
+ const entry = { records: [] };
13373
+ if (record.tier != null) entry.tier = record.tier;
13374
+ skillMap.set(record.skill, entry);
13375
+ }
13376
+ skillMap.get(record.skill).records.push(record);
13377
+ }
13378
+ const results = [];
13379
+ for (const [skill, bucket] of skillMap) {
13380
+ const invocations = bucket.records.length;
13381
+ const completedCount = bucket.records.filter((r) => r.outcome === "completed").length;
13382
+ const totalDuration = bucket.records.reduce((sum, r) => sum + r.duration, 0);
13383
+ const timestamps = bucket.records.map((r) => r.startedAt).sort();
13384
+ const summary = {
13385
+ skill,
13386
+ invocations,
13387
+ successRate: completedCount / invocations,
13388
+ avgDuration: totalDuration / invocations,
13389
+ lastUsed: timestamps[timestamps.length - 1]
13390
+ };
13391
+ if (bucket.tier != null) summary.tier = bucket.tier;
13392
+ results.push(summary);
13393
+ }
13394
+ results.sort((a, b) => b.invocations - a.invocations);
13395
+ return results;
13396
+ }
13397
+ function aggregateByDay2(records) {
13398
+ if (records.length === 0) return [];
13399
+ const dayMap = /* @__PURE__ */ new Map();
13400
+ for (const record of records) {
13401
+ const date = record.startedAt.slice(0, 10);
13402
+ if (!dayMap.has(date)) {
13403
+ dayMap.set(date, { invocations: 0, skills: /* @__PURE__ */ new Set() });
13404
+ }
13405
+ const bucket = dayMap.get(date);
13406
+ bucket.invocations++;
13407
+ bucket.skills.add(record.skill);
13408
+ }
13409
+ const results = [];
13410
+ for (const [date, bucket] of dayMap) {
13411
+ results.push({
13412
+ date,
13413
+ invocations: bucket.invocations,
13414
+ uniqueSkills: bucket.skills.size
13415
+ });
13416
+ }
13417
+ results.sort((a, b) => b.date.localeCompare(a.date));
13418
+ return results;
13419
+ }
13420
+ function topSkills(records, n) {
13421
+ return aggregateBySkill(records).slice(0, n);
13422
+ }
13423
+
13424
+ // src/compaction/strategies/structural.ts
13425
+ function isEmptyObject(v) {
13426
+ return typeof v === "object" && v !== null && !Array.isArray(v) && Object.keys(v).length === 0;
13427
+ }
13428
+ function isRetainable(v) {
13429
+ return v !== void 0 && v !== "" && v !== null && !isEmptyObject(v);
13430
+ }
13431
+ function cleanArray(value) {
13432
+ const cleaned = value.map(cleanValue).filter(isRetainable);
13433
+ if (cleaned.length === 0) return void 0;
13434
+ if (cleaned.length === 1) return cleaned[0];
13435
+ return cleaned;
13436
+ }
13437
+ function cleanRecord(value) {
13438
+ const cleaned = {};
13439
+ for (const [k, v] of Object.entries(value)) {
13440
+ const result = cleanValue(v);
13441
+ if (isRetainable(result)) {
13442
+ cleaned[k] = result;
13443
+ }
13444
+ }
13445
+ if (Object.keys(cleaned).length === 0) return void 0;
13446
+ return cleaned;
13447
+ }
13448
+ function cleanValue(value) {
13449
+ if (value === null || value === void 0) return void 0;
13450
+ if (typeof value === "string") return value.replace(/\s+/g, " ").trim();
13451
+ if (Array.isArray(value)) return cleanArray(value);
13452
+ if (typeof value === "object") return cleanRecord(value);
13453
+ return value;
13454
+ }
13455
+ var StructuralStrategy = class {
13456
+ name = "structural";
13457
+ lossy = false;
13458
+ apply(content, _budget) {
13459
+ let parsed;
13460
+ try {
13461
+ parsed = JSON.parse(content);
13462
+ } catch {
13463
+ return content;
13464
+ }
13465
+ const cleaned = cleanValue(parsed);
13466
+ return JSON.stringify(cleaned) ?? "";
13467
+ }
13468
+ };
13469
+
13470
+ // src/compaction/strategies/truncation.ts
13471
+ var DEFAULT_TOKEN_BUDGET = 4e3;
13472
+ var CHARS_PER_TOKEN = 4;
13473
+ var TRUNCATION_MARKER = "\n[truncated \u2014 prioritized truncation applied]";
13474
+ function lineScore(line) {
13475
+ let score = 0;
13476
+ if (/\/[\w./-]/.test(line)) score += 40;
13477
+ if (/error|Error|ERROR|fail|FAIL|status/i.test(line)) score += 35;
13478
+ if (/\b[A-Z][a-z]+[A-Z]/.test(line) || /\b[a-z]+[A-Z]/.test(line)) score += 20;
13479
+ if (line.trim().length < 40) score += 10;
13480
+ return score;
13481
+ }
13482
+ function selectLines(lines, charBudget) {
13483
+ const scored = lines.map((line, idx) => ({ line, idx, score: lineScore(line) }));
13484
+ scored.sort((a, b) => b.score - a.score || a.idx - b.idx);
13485
+ const kept = [];
13486
+ let used = 0;
13487
+ for (const item of scored) {
13488
+ const lineLen = item.line.length + 1;
13489
+ if (used + lineLen > charBudget) continue;
13490
+ kept.push({ line: item.line, idx: item.idx });
13491
+ used += lineLen;
13492
+ }
13493
+ kept.sort((a, b) => a.idx - b.idx);
13494
+ return kept;
13495
+ }
13496
+ var TruncationStrategy = class {
13497
+ name = "truncate";
13498
+ lossy = false;
13499
+ // deliberate: spec Decision 2 — truncation is classified lossless at the pipeline level
13500
+ apply(content, budget = DEFAULT_TOKEN_BUDGET) {
13501
+ if (!content) return content;
13502
+ const charBudget = budget * CHARS_PER_TOKEN;
13503
+ if (content.length <= charBudget) return content;
13504
+ const lines = content.split("\n");
13505
+ const available = charBudget - TRUNCATION_MARKER.length;
13506
+ const kept = available > 0 ? selectLines(lines, available) : [{ line: (lines[0] ?? "").slice(0, charBudget), idx: 0 }];
13507
+ return kept.map((k) => k.line).join("\n") + TRUNCATION_MARKER;
13508
+ }
13509
+ };
13510
+
13511
+ // src/compaction/pipeline.ts
13512
+ var CompactionPipeline = class {
13513
+ strategies;
13514
+ constructor(strategies) {
13515
+ this.strategies = strategies;
13516
+ }
13517
+ /** The ordered list of strategy names in this pipeline. */
13518
+ get strategyNames() {
13519
+ return this.strategies.map((s) => s.name);
13520
+ }
13521
+ /**
13522
+ * Apply all strategies in order.
13523
+ * @param content — input string
13524
+ * @param budget — optional token budget forwarded to each strategy
13525
+ */
13526
+ apply(content, budget) {
13527
+ return this.strategies.reduce((current, strategy) => {
13528
+ return strategy.apply(current, budget);
13529
+ }, content);
13530
+ }
13531
+ };
13532
+
13533
+ // src/caching/stability.ts
13534
+ import { NODE_STABILITY } from "@harness-engineering/graph";
13535
+ var STABILITY_LOOKUP = {};
13536
+ for (const [key, tier] of Object.entries(NODE_STABILITY)) {
13537
+ STABILITY_LOOKUP[key] = tier;
13538
+ STABILITY_LOOKUP[key.toLowerCase()] = tier;
13539
+ }
13540
+ STABILITY_LOOKUP["packed_summary"] = "session";
13541
+ STABILITY_LOOKUP["skill"] = "static";
13542
+ function resolveStability(contentType) {
13543
+ return STABILITY_LOOKUP[contentType] ?? "ephemeral";
13544
+ }
13545
+
13546
+ // src/caching/adapters/anthropic.ts
13547
+ var TIER_ORDER = {
13548
+ static: 0,
13549
+ session: 1,
13550
+ ephemeral: 2
13551
+ };
13552
+ var AnthropicCacheAdapter = class {
13553
+ provider = "claude";
13554
+ wrapSystemBlock(content, stability) {
13555
+ if (stability === "ephemeral") {
13556
+ return { type: "text", text: content };
13557
+ }
13558
+ const ttl = stability === "static" ? "1h" : void 0;
13559
+ return {
13560
+ type: "text",
13561
+ text: content,
13562
+ cache_control: {
13563
+ type: "ephemeral",
13564
+ ...ttl !== void 0 && { ttl }
13565
+ }
13566
+ };
13567
+ }
13568
+ wrapTools(tools, stability) {
13569
+ if (tools.length === 0 || stability === "ephemeral") {
13570
+ return { tools: tools.map((t) => ({ ...t })) };
13571
+ }
13572
+ const wrapped = tools.map((t) => ({ ...t }));
13573
+ const last = wrapped[wrapped.length - 1];
13574
+ if (last) {
13575
+ last.cache_control = { type: "ephemeral" };
13576
+ }
13577
+ return { tools: wrapped };
13578
+ }
13579
+ orderContent(blocks) {
13580
+ return [...blocks].sort((a, b) => TIER_ORDER[a.stability] - TIER_ORDER[b.stability]);
13581
+ }
13582
+ parseCacheUsage(response) {
13583
+ const resp = response;
13584
+ const usage = resp?.usage;
13585
+ return {
13586
+ cacheCreationTokens: usage?.cache_creation_input_tokens ?? 0,
13587
+ cacheReadTokens: usage?.cache_read_input_tokens ?? 0
13588
+ };
13589
+ }
13590
+ };
13591
+
13592
+ // src/caching/adapters/openai.ts
13593
+ var TIER_ORDER2 = {
13594
+ static: 0,
13595
+ session: 1,
13596
+ ephemeral: 2
13597
+ };
13598
+ var OpenAICacheAdapter = class {
13599
+ provider = "openai";
13600
+ wrapSystemBlock(content, _stability) {
13601
+ return { type: "text", text: content };
13602
+ }
13603
+ wrapTools(tools, _stability) {
13604
+ return { tools: tools.map((t) => ({ ...t })) };
13605
+ }
13606
+ orderContent(blocks) {
13607
+ return [...blocks].sort((a, b) => TIER_ORDER2[a.stability] - TIER_ORDER2[b.stability]);
13608
+ }
13609
+ parseCacheUsage(response) {
13610
+ const resp = response;
13611
+ const usage = resp?.usage;
13612
+ const details = usage?.prompt_tokens_details;
13613
+ return {
13614
+ cacheCreationTokens: 0,
13615
+ cacheReadTokens: details?.cached_tokens ?? 0
13616
+ };
13617
+ }
13618
+ };
13619
+
13620
+ // src/caching/adapters/gemini.ts
13621
+ var TIER_ORDER3 = {
13622
+ static: 0,
13623
+ session: 1,
13624
+ ephemeral: 2
13625
+ };
13626
+ var CACHED_CONTENT_MARKER = "cachedContents:pending";
13627
+ var GeminiCacheAdapter = class {
13628
+ provider = "gemini";
13629
+ wrapSystemBlock(content, stability) {
13630
+ if (stability === "static") {
13631
+ return {
13632
+ type: "text",
13633
+ text: content,
13634
+ cachedContentRef: CACHED_CONTENT_MARKER
13635
+ };
13636
+ }
13637
+ return { type: "text", text: content };
13638
+ }
13639
+ wrapTools(tools, _stability) {
13640
+ return { tools: tools.map((t) => ({ ...t })) };
13641
+ }
13642
+ orderContent(blocks) {
13643
+ return [...blocks].sort((a, b) => TIER_ORDER3[a.stability] - TIER_ORDER3[b.stability]);
13644
+ }
13645
+ parseCacheUsage(response) {
13646
+ const resp = response;
13647
+ const metadata = resp?.usageMetadata;
13648
+ return {
13649
+ cacheCreationTokens: 0,
13650
+ cacheReadTokens: metadata?.cachedContentTokenCount ?? 0
13651
+ };
13652
+ }
13653
+ };
13654
+
13655
+ // src/telemetry/consent.ts
13656
+ import * as fs34 from "fs";
13657
+ import * as path33 from "path";
13658
+
13659
+ // src/telemetry/install-id.ts
13660
+ import * as fs33 from "fs";
13661
+ import * as path32 from "path";
13662
+ import * as crypto4 from "crypto";
13663
+ function getOrCreateInstallId(projectRoot) {
13664
+ const harnessDir = path32.join(projectRoot, ".harness");
13665
+ const installIdFile = path32.join(harnessDir, ".install-id");
13666
+ const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
13667
+ try {
13668
+ const existing = fs33.readFileSync(installIdFile, "utf-8").trim();
13669
+ if (UUID_V4_RE.test(existing)) {
13670
+ return existing;
13671
+ }
13672
+ } catch {
13673
+ }
13674
+ const id = crypto4.randomUUID();
13675
+ fs33.mkdirSync(harnessDir, { recursive: true });
13676
+ fs33.writeFileSync(installIdFile, id, { encoding: "utf-8", mode: 384 });
13677
+ return id;
13678
+ }
13679
+
13680
+ // src/telemetry/consent.ts
13681
+ function readIdentity(projectRoot) {
13682
+ const filePath = path33.join(projectRoot, ".harness", "telemetry.json");
13683
+ try {
13684
+ const raw = fs34.readFileSync(filePath, "utf-8");
13685
+ const parsed = JSON.parse(raw);
13686
+ if (parsed && typeof parsed === "object" && parsed.identity) {
13687
+ const { project, team, alias } = parsed.identity;
13688
+ const identity = {};
13689
+ if (typeof project === "string") identity.project = project;
13690
+ if (typeof team === "string") identity.team = team;
13691
+ if (typeof alias === "string") identity.alias = alias;
13692
+ return identity;
13693
+ }
13694
+ return {};
13695
+ } catch {
13696
+ return {};
13697
+ }
13698
+ }
13699
+ function resolveConsent(projectRoot, config) {
13700
+ if (process.env.DO_NOT_TRACK === "1") return { allowed: false };
13701
+ if (process.env.HARNESS_TELEMETRY_OPTOUT === "1") return { allowed: false };
13702
+ const enabled = config?.enabled ?? true;
13703
+ if (!enabled) return { allowed: false };
13704
+ const installId = getOrCreateInstallId(projectRoot);
13705
+ const identity = readIdentity(projectRoot);
13706
+ return { allowed: true, installId, identity };
13707
+ }
13708
+
13709
+ // src/version.ts
13710
+ var VERSION = "0.21.3";
13711
+
13712
+ // src/telemetry/collector.ts
13713
+ function mapOutcome(outcome) {
13714
+ return outcome === "completed" ? "success" : "failure";
13715
+ }
13716
+ function collectEvents(projectRoot, consent) {
13717
+ const records = readAdoptionRecords(projectRoot);
13718
+ if (records.length === 0) return [];
13719
+ const { installId, identity } = consent;
13720
+ const distinctId = identity.alias ?? installId;
13721
+ return records.map(
13722
+ (record) => ({
13723
+ event: "skill_invocation",
13724
+ distinctId,
13725
+ timestamp: record.startedAt,
13726
+ properties: {
13727
+ installId,
13728
+ os: process.platform,
13729
+ nodeVersion: process.version,
13730
+ harnessVersion: VERSION,
13731
+ skillName: record.skill,
13732
+ duration: record.duration,
13733
+ outcome: mapOutcome(record.outcome),
13734
+ phasesReached: record.phasesReached,
13735
+ ...identity.project ? { project: identity.project } : {},
13736
+ ...identity.team ? { team: identity.team } : {}
13737
+ }
13738
+ })
13739
+ );
13740
+ }
13741
+
13742
+ // src/telemetry/transport.ts
13743
+ var POSTHOG_BATCH_URL = "https://app.posthog.com/batch";
13744
+ var MAX_ATTEMPTS = 3;
13745
+ var TIMEOUT_MS = 5e3;
13746
+ function sleep2(ms) {
13747
+ return new Promise((resolve5) => setTimeout(resolve5, ms));
13748
+ }
13749
+ async function send(events, apiKey) {
13750
+ if (events.length === 0) return;
13751
+ const payload = { api_key: apiKey, batch: events };
13752
+ const body = JSON.stringify(payload);
13753
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
13754
+ try {
13755
+ const res = await fetch(POSTHOG_BATCH_URL, {
13756
+ method: "POST",
13757
+ headers: { "Content-Type": "application/json" },
13758
+ body,
13759
+ signal: AbortSignal.timeout(TIMEOUT_MS)
13760
+ });
13761
+ if (res.ok) return;
13762
+ if (res.status < 500) return;
13763
+ } catch {
13764
+ }
13765
+ if (attempt < MAX_ATTEMPTS - 1) {
13766
+ await sleep2(1e3 * (attempt + 1));
13767
+ }
13768
+ }
13769
+ }
13168
13770
  export {
13169
13771
  AGENT_DESCRIPTORS,
13170
13772
  ARCHITECTURE_DESCRIPTOR,
13171
13773
  AdjustedForecastSchema,
13172
13774
  AgentActionEmitter,
13775
+ AnthropicCacheAdapter,
13173
13776
  ArchBaselineManager,
13174
13777
  ArchBaselineSchema,
13175
13778
  ArchConfigSchema,
@@ -13189,12 +13792,12 @@ export {
13189
13792
  CategorySnapshotSchema,
13190
13793
  ChecklistBuilder,
13191
13794
  CircularDepsCollector,
13795
+ CompactionPipeline,
13192
13796
  ComplexityCollector,
13193
13797
  ConfidenceTierSchema,
13194
13798
  ConfirmationSchema,
13195
13799
  ConsoleSink,
13196
13800
  ConstraintRuleSchema,
13197
- ContentPipeline,
13198
13801
  ContributingFeatureSchema,
13199
13802
  ContributionsSchema,
13200
13803
  CouplingCollector,
@@ -13204,6 +13807,7 @@ export {
13204
13807
  DEFAULT_STABILITY_THRESHOLDS,
13205
13808
  DEFAULT_STATE,
13206
13809
  DEFAULT_STREAM_INDEX,
13810
+ DEFAULT_TOKEN_BUDGET,
13207
13811
  DESTRUCTIVE_BASH,
13208
13812
  DepDepthCollector,
13209
13813
  DirectionSchema,
@@ -13217,6 +13821,7 @@ export {
13217
13821
  ForbiddenImportCollector,
13218
13822
  GateConfigSchema,
13219
13823
  GateResultSchema,
13824
+ GeminiCacheAdapter,
13220
13825
  GitHubIssuesSyncAdapter,
13221
13826
  HandoffSchema,
13222
13827
  HarnessStateSchema,
@@ -13231,6 +13836,7 @@ export {
13231
13836
  NoOpExecutor,
13232
13837
  NoOpSink,
13233
13838
  NoOpTelemetryAdapter,
13839
+ OpenAICacheAdapter,
13234
13840
  PatternConfigSchema,
13235
13841
  PredictionEngine,
13236
13842
  PredictionOptionsSchema,
@@ -13258,6 +13864,7 @@ export {
13258
13864
  StabilityForecastSchema,
13259
13865
  StreamIndexSchema,
13260
13866
  StreamInfoSchema,
13867
+ StructuralStrategy,
13261
13868
  ThresholdConfigSchema,
13262
13869
  TimelineFileSchema,
13263
13870
  TimelineManager,
@@ -13265,13 +13872,16 @@ export {
13265
13872
  TransitionSchema,
13266
13873
  TrendLineSchema,
13267
13874
  TrendResultSchema,
13875
+ TruncationStrategy,
13268
13876
  TypeScriptParser,
13269
13877
  VERSION,
13270
13878
  ViolationSchema,
13271
13879
  addProvenance,
13272
13880
  agentConfigRules,
13881
+ aggregateByDay2 as aggregateAdoptionByDay,
13273
13882
  aggregateByDay,
13274
13883
  aggregateBySession,
13884
+ aggregateBySkill,
13275
13885
  analyzeDiff,
13276
13886
  analyzeLearningPatterns,
13277
13887
  appendFailure,
@@ -13303,6 +13913,7 @@ export {
13303
13913
  clearFailuresCache,
13304
13914
  clearLearningsCache,
13305
13915
  clearTaint,
13916
+ collectEvents,
13306
13917
  computeContentHash,
13307
13918
  computeOverallSeverity,
13308
13919
  computeScanExitCode,
@@ -13342,6 +13953,7 @@ export {
13342
13953
  determineAssessment,
13343
13954
  diff,
13344
13955
  emitEvent,
13956
+ estimateTokens,
13345
13957
  executeWorkflow,
13346
13958
  expressRules,
13347
13959
  extractBundle,
@@ -13363,6 +13975,7 @@ export {
13363
13975
  getFeedbackConfig,
13364
13976
  getInjectionPatterns,
13365
13977
  getModelPrice,
13978
+ getOrCreateInstallId,
13366
13979
  getOutline,
13367
13980
  getParser,
13368
13981
  getPhaseCategories,
@@ -13415,8 +14028,10 @@ export {
13415
14028
  promoteSessionLearnings,
13416
14029
  pruneLearnings,
13417
14030
  reactRules,
14031
+ readAdoptionRecords,
13418
14032
  readCheckState,
13419
14033
  readCostRecords,
14034
+ readIdentity,
13420
14035
  readLockfile,
13421
14036
  readSessionSection,
13422
14037
  readSessionSections,
@@ -13427,11 +14042,13 @@ export {
13427
14042
  requestPeerReview,
13428
14043
  resetFeedbackConfig,
13429
14044
  resetParserCache,
14045
+ resolveConsent,
13430
14046
  resolveFileToLayer,
13431
14047
  resolveModelTier,
13432
14048
  resolveReverseStatus,
13433
14049
  resolveRuleSeverity,
13434
14050
  resolveSessionDir,
14051
+ resolveStability,
13435
14052
  resolveStreamPath,
13436
14053
  resolveThresholds,
13437
14054
  runAll,
@@ -13453,6 +14070,8 @@ export {
13453
14070
  scoreRoadmapCandidates,
13454
14071
  searchSymbols,
13455
14072
  secretRules,
14073
+ send,
14074
+ serializeEnvelope,
13456
14075
  serializeRoadmap,
13457
14076
  setActiveStream,
13458
14077
  sharpEdgesRules,
@@ -13463,6 +14082,7 @@ export {
13463
14082
  syncRoadmap,
13464
14083
  syncToExternal,
13465
14084
  tagUncitedFindings,
14085
+ topSkills,
13466
14086
  touchStream,
13467
14087
  trackAction,
13468
14088
  unfoldRange,