@harness-engineering/core 0.21.1 → 0.21.2

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";
@@ -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
@@ -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;
@@ -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;
@@ -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 path31 = parseDiffHeader(part);
4006
+ if (!path31) return null;
4007
+ const additionRegex = /^\+(?!\+\+)/gm;
3959
4008
  const deletionRegex = /^-(?!--)/gm;
3960
4009
  return {
3961
- path: headerMatch[2],
4010
+ path: path31,
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,7 @@ 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";
6277
6352
  function parseFrontmatter2(line) {
6278
6353
  const match = line.match(/^<!--\s+hash:([a-f0-9]+)(?:\s+tags:([^\s]+))?\s+-->/);
6279
6354
  if (!match) return null;
@@ -6301,7 +6376,7 @@ function extractIndexEntry(entry) {
6301
6376
  };
6302
6377
  }
6303
6378
  function computeEntryHash(text) {
6304
- return crypto.createHash("sha256").update(text).digest("hex").slice(0, 8);
6379
+ return crypto2.createHash("sha256").update(text).digest("hex").slice(0, 8);
6305
6380
  }
6306
6381
  function normalizeLearningContent(text) {
6307
6382
  let normalized = text;
@@ -6316,7 +6391,7 @@ function normalizeLearningContent(text) {
6316
6391
  return normalized;
6317
6392
  }
6318
6393
  function computeContentHash(text) {
6319
- return crypto.createHash("sha256").update(text).digest("hex").slice(0, 16);
6394
+ return crypto2.createHash("sha256").update(text).digest("hex").slice(0, 16);
6320
6395
  }
6321
6396
  function loadContentHashes(stateDir) {
6322
6397
  const hashesPath = path8.join(stateDir, CONTENT_HASHES_FILE);
@@ -6456,7 +6531,7 @@ async function loadRelevantLearnings(projectPath, skillName, stream, session) {
6456
6531
  // src/state/learnings.ts
6457
6532
  import * as fs13 from "fs";
6458
6533
  import * as path10 from "path";
6459
- import * as crypto2 from "crypto";
6534
+ import * as crypto3 from "crypto";
6460
6535
  async function appendLearning(projectPath, learning, skillName, outcome, stream, session) {
6461
6536
  try {
6462
6537
  const dirResult = await getStateDir(projectPath, stream, session);
@@ -6493,7 +6568,7 @@ async function appendLearning(projectPath, learning, skillName, outcome, stream,
6493
6568
  } else {
6494
6569
  bulletLine = `- **${timestamp}:** ${learning}`;
6495
6570
  }
6496
- const hash = crypto2.createHash("sha256").update(bulletLine).digest("hex").slice(0, 8);
6571
+ const hash = crypto3.createHash("sha256").update(bulletLine).digest("hex").slice(0, 8);
6497
6572
  const tagsStr = fmTags.length > 0 ? ` tags:${fmTags.join(",")}` : "";
6498
6573
  const frontmatter = `<!-- hash:${hash}${tagsStr} -->`;
6499
6574
  const entry = `
@@ -7204,7 +7279,7 @@ async function updateSessionEntryStatus(projectPath, sessionSlug, section, entry
7204
7279
  }
7205
7280
  function generateEntryId() {
7206
7281
  const timestamp = Date.now().toString(36);
7207
- const random = Math.random().toString(36).substring(2, 8);
7282
+ const random = Buffer.from(crypto.getRandomValues(new Uint8Array(4))).toString("hex");
7208
7283
  return `${timestamp}-${random}`;
7209
7284
  }
7210
7285
 
@@ -7349,15 +7424,25 @@ async function loadEvents(projectPath, options) {
7349
7424
  );
7350
7425
  }
7351
7426
  }
7427
+ function phaseTransitionFields(data) {
7428
+ return {
7429
+ from: data?.from ?? "?",
7430
+ to: data?.to ?? "?",
7431
+ suffix: data?.taskCount ? ` (${data.taskCount} tasks)` : ""
7432
+ };
7433
+ }
7352
7434
  function formatPhaseTransition(event) {
7353
7435
  const data = event.data;
7354
- const suffix = data?.taskCount ? ` (${data.taskCount} tasks)` : "";
7355
- return `phase: ${data?.from ?? "?"} -> ${data?.to ?? "?"}${suffix}`;
7436
+ const { from, to, suffix } = phaseTransitionFields(data);
7437
+ return `phase: ${from} -> ${to}${suffix}`;
7438
+ }
7439
+ function formatGateChecks(checks) {
7440
+ return checks?.map((c) => `${c.name} ${c.passed ? "Y" : "N"}`).join(", ");
7356
7441
  }
7357
7442
  function formatGateResult(event) {
7358
7443
  const data = event.data;
7359
7444
  const status = data?.passed ? "passed" : "failed";
7360
- const checks = data?.checks?.map((c) => `${c.name} ${c.passed ? "Y" : "N"}`).join(", ");
7445
+ const checks = formatGateChecks(data?.checks);
7361
7446
  return checks ? `gate: ${status} (${checks})` : `gate: ${status}`;
7362
7447
  }
7363
7448
  function formatHandoffDetail(event) {
@@ -7633,34 +7718,36 @@ function resolveRuleSeverity(ruleId, defaultSeverity, overrides, strict) {
7633
7718
  // src/security/stack-detector.ts
7634
7719
  import * as fs22 from "fs";
7635
7720
  import * as path19 from "path";
7636
- function detectStack(projectRoot) {
7637
- const stacks = [];
7721
+ function nodeSubStacks(allDeps) {
7722
+ const found = [];
7723
+ if (allDeps.react || allDeps["react-dom"]) found.push("react");
7724
+ if (allDeps.express) found.push("express");
7725
+ if (allDeps.koa) found.push("koa");
7726
+ if (allDeps.fastify) found.push("fastify");
7727
+ if (allDeps.next) found.push("next");
7728
+ if (allDeps.vue) found.push("vue");
7729
+ if (allDeps.angular || allDeps["@angular/core"]) found.push("angular");
7730
+ return found;
7731
+ }
7732
+ function detectNodeStacks(projectRoot) {
7638
7733
  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
- }
7734
+ if (!fs22.existsSync(pkgJsonPath)) return [];
7735
+ const stacks = ["node"];
7736
+ try {
7737
+ const pkgJson = JSON.parse(fs22.readFileSync(pkgJsonPath, "utf-8"));
7738
+ const allDeps = { ...pkgJson.dependencies, ...pkgJson.devDependencies };
7739
+ stacks.push(...nodeSubStacks(allDeps));
7740
+ } catch {
7656
7741
  }
7657
- const goModPath = path19.join(projectRoot, "go.mod");
7658
- if (fs22.existsSync(goModPath)) {
7742
+ return stacks;
7743
+ }
7744
+ function detectStack(projectRoot) {
7745
+ const stacks = [...detectNodeStacks(projectRoot)];
7746
+ if (fs22.existsSync(path19.join(projectRoot, "go.mod"))) {
7659
7747
  stacks.push("go");
7660
7748
  }
7661
- const requirementsPath = path19.join(projectRoot, "requirements.txt");
7662
- const pyprojectPath = path19.join(projectRoot, "pyproject.toml");
7663
- if (fs22.existsSync(requirementsPath) || fs22.existsSync(pyprojectPath)) {
7749
+ const hasPython = fs22.existsSync(path19.join(projectRoot, "requirements.txt")) || fs22.existsSync(path19.join(projectRoot, "pyproject.toml"));
7750
+ if (hasPython) {
7664
7751
  stacks.push("python");
7665
7752
  }
7666
7753
  return stacks;
@@ -8504,6 +8591,56 @@ var SecurityScanner = class {
8504
8591
  });
8505
8592
  return this.scanLinesWithRules(lines, applicableRules, filePath, startLine);
8506
8593
  }
8594
+ /** Build a finding for a suppression comment that is missing its justification. */
8595
+ buildSuppressionFinding(rule, filePath, lineNumber, line) {
8596
+ return {
8597
+ ruleId: rule.id,
8598
+ ruleName: rule.name,
8599
+ category: rule.category,
8600
+ severity: this.config.strict ? "error" : "warning",
8601
+ confidence: "high",
8602
+ file: filePath,
8603
+ line: lineNumber,
8604
+ match: line.trim(),
8605
+ context: line,
8606
+ message: `Suppression of ${rule.id} requires justification: // harness-ignore ${rule.id}: <reason>`,
8607
+ remediation: `Add justification after colon: // harness-ignore ${rule.id}: false positive because ...`
8608
+ };
8609
+ }
8610
+ /** Check one line against a rule's patterns; return a finding or null. */
8611
+ matchRuleLine(rule, resolved, filePath, lineNumber, line) {
8612
+ for (const pattern of rule.patterns) {
8613
+ pattern.lastIndex = 0;
8614
+ if (!pattern.test(line)) continue;
8615
+ return {
8616
+ ruleId: rule.id,
8617
+ ruleName: rule.name,
8618
+ category: rule.category,
8619
+ severity: resolved,
8620
+ confidence: rule.confidence,
8621
+ file: filePath,
8622
+ line: lineNumber,
8623
+ match: line.trim(),
8624
+ context: line,
8625
+ message: rule.message,
8626
+ remediation: rule.remediation,
8627
+ ...rule.references ? { references: rule.references } : {}
8628
+ };
8629
+ }
8630
+ return null;
8631
+ }
8632
+ /** Scan a single line against a resolved rule; push any findings into the array. */
8633
+ scanLineForRule(rule, resolved, line, lineNumber, filePath, findings) {
8634
+ const suppressionMatch = parseHarnessIgnore(line, rule.id);
8635
+ if (suppressionMatch) {
8636
+ if (!suppressionMatch.justification) {
8637
+ findings.push(this.buildSuppressionFinding(rule, filePath, lineNumber, line));
8638
+ }
8639
+ return;
8640
+ }
8641
+ const finding = this.matchRuleLine(rule, resolved, filePath, lineNumber, line);
8642
+ if (finding) findings.push(finding);
8643
+ }
8507
8644
  /**
8508
8645
  * Core scanning loop shared by scanContent and scanContentForFile.
8509
8646
  * Evaluates each rule against each line, handling suppression (FP gate)
@@ -8520,46 +8657,7 @@ var SecurityScanner = class {
8520
8657
  );
8521
8658
  if (resolved === "off") continue;
8522
8659
  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
- }
8660
+ this.scanLineForRule(rule, resolved, lines[i] ?? "", startLine + i, filePath, findings);
8563
8661
  }
8564
8662
  }
8565
8663
  return findings;
@@ -9979,37 +10077,30 @@ function extractConventionRules(bundle) {
9979
10077
  }
9980
10078
  return rules;
9981
10079
  }
9982
- function findMissingJsDoc(bundle) {
10080
+ var EXPORT_RE = /export\s+(?:async\s+)?(?:function|const|class|interface|type)\s+(\w+)/;
10081
+ function hasPrecedingJsDoc(lines, i) {
10082
+ for (let j = i - 1; j >= 0; j--) {
10083
+ const prev = lines[j].trim();
10084
+ if (prev === "") continue;
10085
+ return prev.endsWith("*/");
10086
+ }
10087
+ return false;
10088
+ }
10089
+ function scanFileForMissingJsDoc(filePath, lines) {
9983
10090
  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
- }
10091
+ for (let i = 0; i < lines.length; i++) {
10092
+ const exportMatch = lines[i].match(EXPORT_RE);
10093
+ if (exportMatch && !hasPrecedingJsDoc(lines, i)) {
10094
+ missing.push({ file: filePath, line: i + 1, exportName: exportMatch[1] });
10009
10095
  }
10010
10096
  }
10011
10097
  return missing;
10012
10098
  }
10099
+ function findMissingJsDoc(bundle) {
10100
+ return bundle.changedFiles.flatMap(
10101
+ (cf) => scanFileForMissingJsDoc(cf.path, cf.content.split("\n"))
10102
+ );
10103
+ }
10013
10104
  function checkMissingJsDoc(bundle, rules) {
10014
10105
  const jsDocRule = rules.find((r) => r.text.toLowerCase().includes("jsdoc"));
10015
10106
  if (!jsDocRule) return [];
@@ -10074,29 +10165,27 @@ function checkChangeTypeSpecific(bundle) {
10074
10165
  return [];
10075
10166
  }
10076
10167
  }
10168
+ function checkFileResultTypeConvention(cf, bundle, rule) {
10169
+ const hasTryCatch = cf.content.includes("try {") || cf.content.includes("try{");
10170
+ const usesResult = cf.content.includes("Result<") || cf.content.includes("Result >") || cf.content.includes(": Result");
10171
+ if (!hasTryCatch || usesResult) return null;
10172
+ return {
10173
+ id: makeFindingId("compliance", cf.path, 1, "try-catch not Result"),
10174
+ file: cf.path,
10175
+ lineRange: [1, cf.lines],
10176
+ domain: "compliance",
10177
+ severity: "suggestion",
10178
+ title: "Fallible operation uses try/catch instead of Result type",
10179
+ rationale: `Convention requires using Result type for fallible operations (from ${rule.source}).`,
10180
+ suggestion: "Refactor error handling to use the Result type pattern.",
10181
+ evidence: [`changeType: ${bundle.changeType}`, `Convention rule: "${rule.text}"`],
10182
+ validatedBy: "heuristic"
10183
+ };
10184
+ }
10077
10185
  function checkResultTypeConvention(bundle, rules) {
10078
10186
  const resultTypeRule = rules.find((r) => r.text.toLowerCase().includes("result type"));
10079
10187
  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;
10188
+ return bundle.changedFiles.map((cf) => checkFileResultTypeConvention(cf, bundle, resultTypeRule)).filter((f) => f !== null);
10100
10189
  }
10101
10190
  function runComplianceAgent(bundle) {
10102
10191
  const rules = extractConventionRules(bundle);
@@ -10122,53 +10211,58 @@ var BUG_DETECTION_DESCRIPTOR = {
10122
10211
  "Test coverage \u2014 tests for happy path, error paths, and edge cases"
10123
10212
  ]
10124
10213
  };
10214
+ function hasPrecedingZeroCheck(lines, i) {
10215
+ const preceding = lines.slice(Math.max(0, i - 3), i).join("\n");
10216
+ return preceding.includes("=== 0") || preceding.includes("!== 0") || preceding.includes("== 0") || preceding.includes("!= 0");
10217
+ }
10125
10218
  function detectDivisionByZero(bundle) {
10126
10219
  const findings = [];
10127
10220
  for (const cf of bundle.changedFiles) {
10128
10221
  const lines = cf.content.split("\n");
10129
10222
  for (let i = 0; i < lines.length; i++) {
10130
10223
  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
- }
10224
+ if (!line.match(/[^=!<>]\s*\/\s*[a-zA-Z_]\w*/) || line.includes("//")) continue;
10225
+ if (hasPrecedingZeroCheck(lines, i)) continue;
10226
+ findings.push({
10227
+ id: makeFindingId("bug", cf.path, i + 1, "division by zero"),
10228
+ file: cf.path,
10229
+ lineRange: [i + 1, i + 1],
10230
+ domain: "bug",
10231
+ severity: "important",
10232
+ title: "Potential division by zero without guard",
10233
+ rationale: "Division operation found without a preceding zero check on the divisor. This can cause Infinity or NaN at runtime.",
10234
+ suggestion: "Add a check for zero before dividing, or use a safe division utility.",
10235
+ evidence: [`Line ${i + 1}: ${line.trim()}`],
10236
+ validatedBy: "heuristic"
10237
+ });
10148
10238
  }
10149
10239
  }
10150
10240
  return findings;
10151
10241
  }
10242
+ function isEmptyCatch(lines, i) {
10243
+ const line = lines[i];
10244
+ if (line.match(/catch\s*\([^)]*\)\s*\{\s*\}/)) return true;
10245
+ return line.match(/catch\s*\([^)]*\)\s*\{/) !== null && i + 1 < lines.length && lines[i + 1].trim() === "}";
10246
+ }
10152
10247
  function detectEmptyCatch(bundle) {
10153
10248
  const findings = [];
10154
10249
  for (const cf of bundle.changedFiles) {
10155
10250
  const lines = cf.content.split("\n");
10156
10251
  for (let i = 0; i < lines.length; i++) {
10252
+ if (!isEmptyCatch(lines, i)) continue;
10157
10253
  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
- }
10254
+ findings.push({
10255
+ id: makeFindingId("bug", cf.path, i + 1, "empty catch block"),
10256
+ file: cf.path,
10257
+ lineRange: [i + 1, i + 2],
10258
+ domain: "bug",
10259
+ severity: "important",
10260
+ title: "Empty catch block silently swallows error",
10261
+ rationale: "Catching an error without handling, logging, or re-throwing it hides failures and makes debugging difficult.",
10262
+ suggestion: "Log the error, re-throw it, or handle it explicitly. If intentionally ignoring, add a comment explaining why.",
10263
+ evidence: [`Line ${i + 1}: ${line.trim()}`],
10264
+ validatedBy: "heuristic"
10265
+ });
10172
10266
  }
10173
10267
  }
10174
10268
  return findings;
@@ -10226,34 +10320,102 @@ var SECRET_PATTERNS = [
10226
10320
  ];
10227
10321
  var SQL_CONCAT_PATTERN = /(?:SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER)\s+.*?\+\s*\w+|`[^`]*\$\{[^}]*\}[^`]*(?:SELECT|INSERT|UPDATE|DELETE|WHERE)/i;
10228
10322
  var SHELL_EXEC_PATTERN = /(?:exec|execSync|spawn|spawnSync)\s*\(\s*`[^`]*\$\{/;
10323
+ function makeEvalFinding(file, lineNum, line) {
10324
+ return {
10325
+ id: makeFindingId("security", file, lineNum, "eval usage CWE-94"),
10326
+ file,
10327
+ lineRange: [lineNum, lineNum],
10328
+ domain: "security",
10329
+ severity: "critical",
10330
+ title: `Dangerous ${"eval"}() or new ${"Function"}() usage`,
10331
+ rationale: `${"eval"}() and new ${"Function"}() execute arbitrary code. If user input reaches these calls, it enables Remote Code Execution (CWE-94).`,
10332
+ suggestion: "Replace eval/Function with a safe alternative (JSON.parse for data, a sandboxed evaluator for expressions).",
10333
+ evidence: [`Line ${lineNum}: ${line.trim()}`],
10334
+ validatedBy: "heuristic",
10335
+ cweId: "CWE-94",
10336
+ owaspCategory: "A03:2021 Injection",
10337
+ confidence: "high",
10338
+ remediation: "Replace eval/Function with a safe alternative (JSON.parse for data, a sandboxed evaluator for expressions).",
10339
+ references: [
10340
+ "https://cwe.mitre.org/data/definitions/94.html",
10341
+ "https://owasp.org/Top10/A03_2021-Injection/"
10342
+ ]
10343
+ };
10344
+ }
10345
+ function makeSecretFinding(file, lineNum) {
10346
+ return {
10347
+ id: makeFindingId("security", file, lineNum, "hardcoded secret CWE-798"),
10348
+ file,
10349
+ lineRange: [lineNum, lineNum],
10350
+ domain: "security",
10351
+ severity: "critical",
10352
+ title: "Hardcoded secret or API key detected",
10353
+ rationale: "Hardcoded secrets in source code can be extracted from version history even after removal. Use environment variables or a secrets manager (CWE-798).",
10354
+ suggestion: "Move the secret to an environment variable and access it via process.env.",
10355
+ evidence: [`Line ${lineNum}: [secret detected \u2014 value redacted]`],
10356
+ validatedBy: "heuristic",
10357
+ cweId: "CWE-798",
10358
+ owaspCategory: "A07:2021 Identification and Authentication Failures",
10359
+ confidence: "high",
10360
+ remediation: "Move the secret to an environment variable and access it via process.env.",
10361
+ references: [
10362
+ "https://cwe.mitre.org/data/definitions/798.html",
10363
+ "https://owasp.org/Top10/A07_2021-Identification_and_Authentication_Failures/"
10364
+ ]
10365
+ };
10366
+ }
10367
+ function makeSqlFinding(file, lineNum, line) {
10368
+ return {
10369
+ id: makeFindingId("security", file, lineNum, "SQL injection CWE-89"),
10370
+ file,
10371
+ lineRange: [lineNum, lineNum],
10372
+ domain: "security",
10373
+ severity: "critical",
10374
+ title: "Potential SQL injection via string concatenation",
10375
+ rationale: "Building SQL queries with string concatenation or template literals allows attackers to inject malicious SQL (CWE-89).",
10376
+ suggestion: "Use parameterized queries or a query builder (e.g., Knex, Prisma) instead of string concatenation.",
10377
+ evidence: [`Line ${lineNum}: ${line.trim()}`],
10378
+ validatedBy: "heuristic",
10379
+ cweId: "CWE-89",
10380
+ owaspCategory: "A03:2021 Injection",
10381
+ confidence: "high",
10382
+ remediation: "Use parameterized queries or a query builder (e.g., Knex, Prisma) instead of string concatenation.",
10383
+ references: [
10384
+ "https://cwe.mitre.org/data/definitions/89.html",
10385
+ "https://owasp.org/Top10/A03_2021-Injection/"
10386
+ ]
10387
+ };
10388
+ }
10389
+ function makeCommandFinding(file, lineNum, line) {
10390
+ return {
10391
+ id: makeFindingId("security", file, lineNum, "command injection CWE-78"),
10392
+ file,
10393
+ lineRange: [lineNum, lineNum],
10394
+ domain: "security",
10395
+ severity: "critical",
10396
+ title: "Potential command injection via shell exec with interpolation",
10397
+ rationale: "Using exec/spawn with template literal interpolation allows attackers to inject shell commands (CWE-78).",
10398
+ suggestion: "Use execFile or spawn with an arguments array instead of shell string interpolation.",
10399
+ evidence: [`Line ${lineNum}: ${line.trim()}`],
10400
+ validatedBy: "heuristic",
10401
+ cweId: "CWE-78",
10402
+ owaspCategory: "A03:2021 Injection",
10403
+ confidence: "high",
10404
+ remediation: "Use execFile or spawn with an arguments array instead of shell string interpolation.",
10405
+ references: [
10406
+ "https://cwe.mitre.org/data/definitions/78.html",
10407
+ "https://owasp.org/Top10/A03_2021-Injection/"
10408
+ ]
10409
+ };
10410
+ }
10229
10411
  function detectEvalUsage(bundle) {
10230
10412
  const findings = [];
10231
10413
  for (const cf of bundle.changedFiles) {
10232
10414
  const lines = cf.content.split("\n");
10233
10415
  for (let i = 0; i < lines.length; i++) {
10234
10416
  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
- }
10417
+ if (!EVAL_PATTERN.test(line)) continue;
10418
+ findings.push(makeEvalFinding(cf.path, i + 1, line));
10257
10419
  }
10258
10420
  }
10259
10421
  return findings;
@@ -10265,31 +10427,9 @@ function detectHardcodedSecrets(bundle) {
10265
10427
  for (let i = 0; i < lines.length; i++) {
10266
10428
  const line = lines[i];
10267
10429
  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
- }
10430
+ const matched = SECRET_PATTERNS.some((p) => p.test(codePart));
10431
+ if (!matched) continue;
10432
+ findings.push(makeSecretFinding(cf.path, i + 1));
10293
10433
  }
10294
10434
  }
10295
10435
  return findings;
@@ -10300,28 +10440,8 @@ function detectSqlInjection(bundle) {
10300
10440
  const lines = cf.content.split("\n");
10301
10441
  for (let i = 0; i < lines.length; i++) {
10302
10442
  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
- }
10443
+ if (!SQL_CONCAT_PATTERN.test(line)) continue;
10444
+ findings.push(makeSqlFinding(cf.path, i + 1, line));
10325
10445
  }
10326
10446
  }
10327
10447
  return findings;
@@ -10332,28 +10452,8 @@ function detectCommandInjection(bundle) {
10332
10452
  const lines = cf.content.split("\n");
10333
10453
  for (let i = 0; i < lines.length; i++) {
10334
10454
  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
- }
10455
+ if (!SHELL_EXEC_PATTERN.test(line)) continue;
10456
+ findings.push(makeCommandFinding(cf.path, i + 1, line));
10357
10457
  }
10358
10458
  }
10359
10459
  return findings;
@@ -10386,10 +10486,15 @@ function isViolationLine(line) {
10386
10486
  const lower = line.toLowerCase();
10387
10487
  return lower.includes("violation") || lower.includes("layer");
10388
10488
  }
10389
- function createLayerViolationFinding(line, fallbackPath) {
10390
- const fileMatch = line.match(/(?:in\s+)?(\S+\.(?:ts|tsx|js|jsx))(?::(\d+))?/);
10489
+ var VIOLATION_FILE_RE = /(?:in\s+)?(\S+\.(?:ts|tsx|js|jsx))(?::(\d+))?/;
10490
+ function extractViolationLocation(line, fallbackPath) {
10491
+ const fileMatch = line.match(VIOLATION_FILE_RE);
10391
10492
  const file = fileMatch?.[1] ?? fallbackPath;
10392
10493
  const lineNum = fileMatch?.[2] ? parseInt(fileMatch[2], 10) : 1;
10494
+ return { file, lineNum };
10495
+ }
10496
+ function createLayerViolationFinding(line, fallbackPath) {
10497
+ const { file, lineNum } = extractViolationLocation(line, fallbackPath);
10393
10498
  return {
10394
10499
  id: makeFindingId("arch", file, lineNum, "layer violation"),
10395
10500
  file,
@@ -10562,6 +10667,26 @@ function normalizePath(filePath, projectRoot) {
10562
10667
  }
10563
10668
  return normalized;
10564
10669
  }
10670
+ function resolveImportPath2(currentFile, importPath) {
10671
+ const dir = path23.dirname(currentFile);
10672
+ let resolved = path23.join(dir, importPath).replace(/\\/g, "/");
10673
+ if (!resolved.match(/\.(ts|tsx|js|jsx)$/)) {
10674
+ resolved += ".ts";
10675
+ }
10676
+ return path23.normalize(resolved).replace(/\\/g, "/");
10677
+ }
10678
+ function enqueueImports(content, current, visited, queue, maxDepth) {
10679
+ const importRegex = /import\s+.*?from\s+['"]([^'"]+)['"]/g;
10680
+ let match;
10681
+ while ((match = importRegex.exec(content)) !== null) {
10682
+ const importPath = match[1];
10683
+ if (!importPath.startsWith(".")) continue;
10684
+ const resolved = resolveImportPath2(current.file, importPath);
10685
+ if (!visited.has(resolved) && current.depth + 1 <= maxDepth) {
10686
+ queue.push({ file: resolved, depth: current.depth + 1 });
10687
+ }
10688
+ }
10689
+ }
10565
10690
  function followImportChain(fromFile, fileContents, maxDepth = 2) {
10566
10691
  const visited = /* @__PURE__ */ new Set();
10567
10692
  const queue = [{ file: fromFile, depth: 0 }];
@@ -10571,82 +10696,63 @@ function followImportChain(fromFile, fileContents, maxDepth = 2) {
10571
10696
  visited.add(current.file);
10572
10697
  const content = fileContents.get(current.file);
10573
10698
  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
- }
10699
+ enqueueImports(content, current, visited, queue, maxDepth);
10589
10700
  }
10590
10701
  visited.delete(fromFile);
10591
10702
  return visited;
10592
10703
  }
10704
+ function isMechanicallyExcluded(finding, exclusionSet, projectRoot) {
10705
+ const normalizedFile = normalizePath(finding.file, projectRoot);
10706
+ if (exclusionSet.isExcluded(normalizedFile, finding.lineRange)) return true;
10707
+ if (exclusionSet.isExcluded(finding.file, finding.lineRange)) return true;
10708
+ const absoluteFile = path23.isAbsolute(finding.file) ? finding.file : path23.join(projectRoot, finding.file).replace(/\\/g, "/");
10709
+ return exclusionSet.isExcluded(absoluteFile, finding.lineRange);
10710
+ }
10711
+ async function validateWithGraph(crossFileRefs, graph) {
10712
+ try {
10713
+ for (const ref of crossFileRefs) {
10714
+ const reachable = await graph.isReachable(ref.from, ref.to);
10715
+ if (!reachable) return { result: "discard" };
10716
+ }
10717
+ return { result: "keep" };
10718
+ } catch {
10719
+ return { result: "fallback" };
10720
+ }
10721
+ }
10722
+ function validateWithHeuristic(finding, crossFileRefs, fileContents, projectRoot) {
10723
+ if (fileContents) {
10724
+ for (const ref of crossFileRefs) {
10725
+ const normalizedFrom = normalizePath(ref.from, projectRoot);
10726
+ const reachable = followImportChain(normalizedFrom, fileContents, 2);
10727
+ const normalizedTo = normalizePath(ref.to, projectRoot);
10728
+ if (reachable.has(normalizedTo)) {
10729
+ return { ...finding, validatedBy: "heuristic" };
10730
+ }
10731
+ }
10732
+ }
10733
+ return {
10734
+ ...finding,
10735
+ severity: DOWNGRADE_MAP[finding.severity],
10736
+ validatedBy: "heuristic"
10737
+ };
10738
+ }
10739
+ async function processFinding(finding, exclusionSet, graph, projectRoot, fileContents) {
10740
+ if (isMechanicallyExcluded(finding, exclusionSet, projectRoot)) return null;
10741
+ const crossFileRefs = extractCrossFileRefs(finding);
10742
+ if (crossFileRefs.length === 0) return { ...finding };
10743
+ if (graph) {
10744
+ const { result } = await validateWithGraph(crossFileRefs, graph);
10745
+ if (result === "keep") return { ...finding, validatedBy: "graph" };
10746
+ if (result === "discard") return null;
10747
+ }
10748
+ return validateWithHeuristic(finding, crossFileRefs, fileContents, projectRoot);
10749
+ }
10593
10750
  async function validateFindings(options) {
10594
10751
  const { findings, exclusionSet, graph, projectRoot, fileContents } = options;
10595
10752
  const validated = [];
10596
10753
  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
- }
10754
+ const result = await processFinding(finding, exclusionSet, graph, projectRoot, fileContents);
10755
+ if (result !== null) validated.push(result);
10650
10756
  }
10651
10757
  return validated;
10652
10758
  }
@@ -11247,25 +11353,32 @@ function serializeRoadmap(roadmap) {
11247
11353
  function serializeMilestoneHeading(milestone) {
11248
11354
  return milestone.isBacklog ? "## Backlog" : `## ${milestone.name}`;
11249
11355
  }
11356
+ function orDash(value) {
11357
+ return value ?? EM_DASH2;
11358
+ }
11359
+ function listOrDash(items) {
11360
+ return items.length > 0 ? items.join(", ") : EM_DASH2;
11361
+ }
11362
+ function serializeExtendedLines(feature) {
11363
+ const hasExtended = feature.assignee !== null || feature.priority !== null || feature.externalId !== null;
11364
+ if (!hasExtended) return [];
11365
+ return [
11366
+ `- **Assignee:** ${orDash(feature.assignee)}`,
11367
+ `- **Priority:** ${orDash(feature.priority)}`,
11368
+ `- **External-ID:** ${orDash(feature.externalId)}`
11369
+ ];
11370
+ }
11250
11371
  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
11372
  const lines = [
11255
11373
  `### ${feature.name}`,
11256
11374
  "",
11257
11375
  `- **Status:** ${feature.status}`,
11258
- `- **Spec:** ${spec}`,
11376
+ `- **Spec:** ${orDash(feature.spec)}`,
11259
11377
  `- **Summary:** ${feature.summary}`,
11260
- `- **Blockers:** ${blockedBy}`,
11261
- `- **Plan:** ${plans}`
11378
+ `- **Blockers:** ${listOrDash(feature.blockedBy)}`,
11379
+ `- **Plan:** ${listOrDash(feature.plans)}`,
11380
+ ...serializeExtendedLines(feature)
11262
11381
  ];
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
11382
  return lines;
11270
11383
  }
11271
11384
  function serializeAssignmentHistory(records) {
@@ -11299,6 +11412,26 @@ function isRegression(from, to) {
11299
11412
  }
11300
11413
 
11301
11414
  // src/roadmap/sync.ts
11415
+ function collectAutopilotStatuses(autopilotPath, featurePlans, allTaskStatuses) {
11416
+ try {
11417
+ const raw = fs24.readFileSync(autopilotPath, "utf-8");
11418
+ const autopilot = JSON.parse(raw);
11419
+ if (!autopilot.phases) return;
11420
+ const linkedPhases = autopilot.phases.filter(
11421
+ (phase) => phase.planPath ? featurePlans.some((p) => p === phase.planPath || phase.planPath.endsWith(p)) : false
11422
+ );
11423
+ for (const phase of linkedPhases) {
11424
+ if (phase.status === "complete") {
11425
+ allTaskStatuses.push("complete");
11426
+ } else if (phase.status === "pending") {
11427
+ allTaskStatuses.push("pending");
11428
+ } else {
11429
+ allTaskStatuses.push("in_progress");
11430
+ }
11431
+ }
11432
+ } catch {
11433
+ }
11434
+ }
11302
11435
  function inferStatus(feature, projectPath, allFeatures) {
11303
11436
  if (feature.blockedBy.length > 0) {
11304
11437
  const blockerNotDone = feature.blockedBy.some((blockerName) => {
@@ -11334,26 +11467,7 @@ function inferStatus(feature, projectPath, allFeatures) {
11334
11467
  if (!entry.isDirectory()) continue;
11335
11468
  const autopilotPath = path24.join(sessionsDir, entry.name, "autopilot-state.json");
11336
11469
  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
- }
11470
+ collectAutopilotStatuses(autopilotPath, feature.plans, allTaskStatuses);
11357
11471
  }
11358
11472
  } catch {
11359
11473
  }
@@ -11670,23 +11784,36 @@ var GitHubIssuesSyncAdapter = class {
11670
11784
  return Err3(error instanceof Error ? error : new Error(String(error)));
11671
11785
  }
11672
11786
  }
11787
+ buildLabelsParam() {
11788
+ const filterLabels = this.config.labels ?? [];
11789
+ return filterLabels.length > 0 ? `&labels=${filterLabels.join(",")}` : "";
11790
+ }
11791
+ issueToTicketState(issue) {
11792
+ return {
11793
+ externalId: buildExternalId(this.owner, this.repo, issue.number),
11794
+ title: issue.title,
11795
+ status: issue.state,
11796
+ labels: issue.labels.map((l) => l.name),
11797
+ assignee: issue.assignee ? `@${issue.assignee.login}` : null
11798
+ };
11799
+ }
11800
+ async fetchIssuePage(page, labelsParam) {
11801
+ const perPage = 100;
11802
+ return fetchWithRetry(
11803
+ this.fetchFn,
11804
+ `${this.apiBase}/repos/${this.owner}/${this.repo}/issues?state=all&per_page=${perPage}&page=${page}${labelsParam}`,
11805
+ { method: "GET", headers: this.headers() },
11806
+ this.retryOpts
11807
+ );
11808
+ }
11673
11809
  async fetchAllTickets() {
11674
11810
  try {
11675
- const filterLabels = this.config.labels ?? [];
11676
- const labelsParam = filterLabels.length > 0 ? `&labels=${filterLabels.join(",")}` : "";
11811
+ const labelsParam = this.buildLabelsParam();
11677
11812
  const tickets = [];
11678
11813
  let page = 1;
11679
11814
  const perPage = 100;
11680
11815
  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
- );
11816
+ const response = await this.fetchIssuePage(page, labelsParam);
11690
11817
  if (!response.ok) {
11691
11818
  const text = await response.text();
11692
11819
  return Err3(new Error(`GitHub API error ${response.status}: ${text}`));
@@ -11694,13 +11821,7 @@ var GitHubIssuesSyncAdapter = class {
11694
11821
  const data = await response.json();
11695
11822
  const issues = data.filter((d) => !d.pull_request);
11696
11823
  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
- });
11824
+ tickets.push(this.issueToTicketState(issue));
11704
11825
  }
11705
11826
  if (data.length < perPage) break;
11706
11827
  page++;
@@ -11795,6 +11916,22 @@ async function syncToExternal(roadmap, adapter, config, prefetchedTickets) {
11795
11916
  }
11796
11917
  return result;
11797
11918
  }
11919
+ function applyTicketToFeature(ticketState, feature, config, forceSync, result) {
11920
+ if (ticketState.assignee !== feature.assignee) {
11921
+ result.assignmentChanges.push({
11922
+ feature: feature.name,
11923
+ from: feature.assignee,
11924
+ to: ticketState.assignee
11925
+ });
11926
+ feature.assignee = ticketState.assignee;
11927
+ }
11928
+ const resolvedStatus = resolveReverseStatus(ticketState.status, ticketState.labels, config);
11929
+ if (!resolvedStatus || resolvedStatus === feature.status) return;
11930
+ const newStatus = resolvedStatus;
11931
+ if (!forceSync && isRegression(feature.status, newStatus)) return;
11932
+ if (!forceSync && feature.status === "blocked" && newStatus === "planned") return;
11933
+ feature.status = newStatus;
11934
+ }
11798
11935
  async function syncFromExternal(roadmap, adapter, config, options, prefetchedTickets) {
11799
11936
  const result = emptySyncResult();
11800
11937
  const forceSync = options?.forceSync ?? false;
@@ -11821,25 +11958,7 @@ async function syncFromExternal(roadmap, adapter, config, options, prefetchedTic
11821
11958
  for (const ticketState of tickets) {
11822
11959
  const feature = featureByExternalId.get(ticketState.externalId);
11823
11960
  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
- }
11961
+ applyTicketToFeature(ticketState, feature, config, forceSync, result);
11843
11962
  }
11844
11963
  return result;
11845
11964
  }
@@ -11887,6 +12006,24 @@ var PRIORITY_RANK = {
11887
12006
  var POSITION_WEIGHT = 0.5;
11888
12007
  var DEPENDENTS_WEIGHT = 0.3;
11889
12008
  var AFFINITY_WEIGHT = 0.2;
12009
+ function isEligibleCandidate(feature, allFeatureNames, doneFeatures) {
12010
+ if (feature.status !== "planned" && feature.status !== "backlog") return false;
12011
+ const isBlocked = feature.blockedBy.some((blocker) => {
12012
+ const key = blocker.toLowerCase();
12013
+ return allFeatureNames.has(key) && !doneFeatures.has(key);
12014
+ });
12015
+ return !isBlocked;
12016
+ }
12017
+ function computeAffinityScore(feature, milestoneName, milestoneMap, userCompletedFeatures) {
12018
+ if (userCompletedFeatures.size === 0) return 0;
12019
+ const completedBlocker = feature.blockedBy.some(
12020
+ (b) => userCompletedFeatures.has(b.toLowerCase())
12021
+ );
12022
+ if (completedBlocker) return 1;
12023
+ const siblings = milestoneMap.get(milestoneName) ?? [];
12024
+ const completedSibling = siblings.some((s) => userCompletedFeatures.has(s));
12025
+ return completedSibling ? 0.5 : 0;
12026
+ }
11890
12027
  function scoreRoadmapCandidates(roadmap, options) {
11891
12028
  const allFeatures = roadmap.milestones.flatMap((m) => m.features);
11892
12029
  const allFeatureNames = new Set(allFeatures.map((f) => f.name.toLowerCase()));
@@ -11925,33 +12062,18 @@ function scoreRoadmapCandidates(roadmap, options) {
11925
12062
  const candidates = [];
11926
12063
  let globalPosition = 0;
11927
12064
  for (const ms of roadmap.milestones) {
11928
- for (let featureIdx = 0; featureIdx < ms.features.length; featureIdx++) {
11929
- const feature = ms.features[featureIdx];
12065
+ for (const feature of ms.features) {
11930
12066
  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;
12067
+ if (!isEligibleCandidate(feature, allFeatureNames, doneFeatures)) continue;
11937
12068
  const positionScore = 1 - (globalPosition - 1) / totalPositions;
11938
12069
  const deps = dependentsCount.get(feature.name.toLowerCase()) ?? 0;
11939
12070
  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
- }
12071
+ const affinityScore = computeAffinityScore(
12072
+ feature,
12073
+ ms.name,
12074
+ milestoneMap,
12075
+ userCompletedFeatures
12076
+ );
11955
12077
  const weightedScore = POSITION_WEIGHT * positionScore + DEPENDENTS_WEIGHT * dependentsScore + AFFINITY_WEIGHT * affinityScore;
11956
12078
  const priorityTier = feature.priority ? PRIORITY_RANK[feature.priority] : null;
11957
12079
  candidates.push({
@@ -13012,6 +13134,27 @@ function aggregateByDay(records) {
13012
13134
  // src/usage/jsonl-reader.ts
13013
13135
  import * as fs30 from "fs";
13014
13136
  import * as path29 from "path";
13137
+ function extractTokenUsage(entry, lineNumber) {
13138
+ const tokenUsage = entry.token_usage;
13139
+ if (!tokenUsage || typeof tokenUsage !== "object") {
13140
+ console.warn(
13141
+ `[harness usage] Skipping malformed JSONL line ${lineNumber}: missing token_usage`
13142
+ );
13143
+ return null;
13144
+ }
13145
+ return tokenUsage;
13146
+ }
13147
+ function applyOptionalFields2(record, entry) {
13148
+ if (entry.cache_creation_tokens != null) {
13149
+ record.cacheCreationTokens = entry.cache_creation_tokens;
13150
+ }
13151
+ if (entry.cache_read_tokens != null) {
13152
+ record.cacheReadTokens = entry.cache_read_tokens;
13153
+ }
13154
+ if (entry.model != null) {
13155
+ record.model = entry.model;
13156
+ }
13157
+ }
13015
13158
  function parseLine(line, lineNumber) {
13016
13159
  let entry;
13017
13160
  try {
@@ -13020,13 +13163,8 @@ function parseLine(line, lineNumber) {
13020
13163
  console.warn(`[harness usage] Skipping malformed JSONL line ${lineNumber}`);
13021
13164
  return null;
13022
13165
  }
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
- }
13166
+ const tokenUsage = extractTokenUsage(entry, lineNumber);
13167
+ if (!tokenUsage) return null;
13030
13168
  const inputTokens = tokenUsage.input_tokens ?? 0;
13031
13169
  const outputTokens = tokenUsage.output_tokens ?? 0;
13032
13170
  const record = {
@@ -13038,15 +13176,7 @@ function parseLine(line, lineNumber) {
13038
13176
  totalTokens: inputTokens + outputTokens
13039
13177
  }
13040
13178
  };
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
- }
13179
+ applyOptionalFields2(record, entry);
13050
13180
  return record;
13051
13181
  }
13052
13182
  function readCostRecords(projectRoot) {
@@ -13081,6 +13211,14 @@ function extractUsage(entry) {
13081
13211
  const usage = message.usage;
13082
13212
  return usage && typeof usage === "object" && !Array.isArray(usage) ? usage : null;
13083
13213
  }
13214
+ function applyOptionalCCFields(record, message, usage) {
13215
+ const model = message.model;
13216
+ if (model) record.model = model;
13217
+ const cacheCreate = usage.cache_creation_input_tokens;
13218
+ const cacheRead = usage.cache_read_input_tokens;
13219
+ if (typeof cacheCreate === "number" && cacheCreate > 0) record.cacheCreationTokens = cacheCreate;
13220
+ if (typeof cacheRead === "number" && cacheRead > 0) record.cacheReadTokens = cacheRead;
13221
+ }
13084
13222
  function buildRecord(entry, usage) {
13085
13223
  const inputTokens = Number(usage.input_tokens) || 0;
13086
13224
  const outputTokens = Number(usage.output_tokens) || 0;
@@ -13091,12 +13229,7 @@ function buildRecord(entry, usage) {
13091
13229
  tokens: { inputTokens, outputTokens, totalTokens: inputTokens + outputTokens },
13092
13230
  _source: "claude-code"
13093
13231
  };
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;
13232
+ applyOptionalCCFields(record, message, usage);
13100
13233
  return record;
13101
13234
  }
13102
13235
  function parseCCLine(line, filePath, lineNumber) {
@@ -13164,7 +13297,7 @@ function parseCCRecords() {
13164
13297
  }
13165
13298
 
13166
13299
  // src/index.ts
13167
- var VERSION = "0.15.0";
13300
+ var VERSION = "0.21.1";
13168
13301
  export {
13169
13302
  AGENT_DESCRIPTORS,
13170
13303
  ARCHITECTURE_DESCRIPTOR,
@@ -13194,7 +13327,6 @@ export {
13194
13327
  ConfirmationSchema,
13195
13328
  ConsoleSink,
13196
13329
  ConstraintRuleSchema,
13197
- ContentPipeline,
13198
13330
  ContributingFeatureSchema,
13199
13331
  ContributionsSchema,
13200
13332
  CouplingCollector,