@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.js CHANGED
@@ -59,7 +59,6 @@ __export(index_exports, {
59
59
  ConfirmationSchema: () => ConfirmationSchema,
60
60
  ConsoleSink: () => ConsoleSink,
61
61
  ConstraintRuleSchema: () => ConstraintRuleSchema,
62
- ContentPipeline: () => ContentPipeline,
63
62
  ContributingFeatureSchema: () => ContributingFeatureSchema,
64
63
  ContributionsSchema: () => ContributionsSchema,
65
64
  CouplingCollector: () => CouplingCollector,
@@ -298,7 +297,7 @@ __export(index_exports, {
298
297
  resolveRuleSeverity: () => resolveRuleSeverity,
299
298
  resolveSessionDir: () => resolveSessionDir,
300
299
  resolveStreamPath: () => resolveStreamPath,
301
- resolveThresholds: () => resolveThresholds2,
300
+ resolveThresholds: () => resolveThresholds3,
302
301
  runAll: () => runAll,
303
302
  runArchitectureAgent: () => runArchitectureAgent,
304
303
  runBugDetectionAgent: () => runBugDetectionAgent,
@@ -499,21 +498,11 @@ function validateCommitMessage(message, format = "conventional") {
499
498
  issues: []
500
499
  });
501
500
  }
502
- function validateConventionalCommit(message) {
503
- const lines = message.split("\n");
504
- const headerLine = lines[0];
505
- if (!headerLine) {
506
- const error = createError(
507
- "VALIDATION_FAILED",
508
- "Commit message header cannot be empty",
509
- { message },
510
- ["Provide a commit message with at least a header line"]
511
- );
512
- return (0, import_types.Err)(error);
513
- }
501
+ function parseConventionalHeader(message, headerLine) {
514
502
  const match = headerLine.match(CONVENTIONAL_PATTERN);
515
- if (!match) {
516
- const error = createError(
503
+ if (match) return (0, import_types.Ok)(match);
504
+ return (0, import_types.Err)(
505
+ createError(
517
506
  "VALIDATION_FAILED",
518
507
  "Commit message does not follow conventional format",
519
508
  { message, header: headerLine },
@@ -522,13 +511,10 @@ function validateConventionalCommit(message) {
522
511
  "Valid types: " + VALID_TYPES.join(", "),
523
512
  "Example: feat(core): add new feature"
524
513
  ]
525
- );
526
- return (0, import_types.Err)(error);
527
- }
528
- const type = match[1];
529
- const scope = match[3];
530
- const breaking = match[4] === "!";
531
- const description = match[5];
514
+ )
515
+ );
516
+ }
517
+ function collectCommitIssues(type, description) {
532
518
  const issues = [];
533
519
  if (!VALID_TYPES.includes(type)) {
534
520
  issues.push(`Invalid commit type "${type}". Valid types: ${VALID_TYPES.join(", ")}`);
@@ -536,34 +522,50 @@ function validateConventionalCommit(message) {
536
522
  if (!description || description.trim() === "") {
537
523
  issues.push("Commit description cannot be empty");
538
524
  }
539
- let hasBreakingChange = breaking;
540
- if (lines.length > 1) {
541
- const body = lines.slice(1).join("\n");
542
- if (body.includes("BREAKING CHANGE:")) {
543
- hasBreakingChange = true;
544
- }
525
+ return issues;
526
+ }
527
+ function hasBreakingChangeInBody(lines) {
528
+ if (lines.length <= 1) return false;
529
+ return lines.slice(1).join("\n").includes("BREAKING CHANGE:");
530
+ }
531
+ function validateConventionalCommit(message) {
532
+ const lines = message.split("\n");
533
+ const headerLine = lines[0];
534
+ if (!headerLine) {
535
+ return (0, import_types.Err)(
536
+ createError(
537
+ "VALIDATION_FAILED",
538
+ "Commit message header cannot be empty",
539
+ { message },
540
+ ["Provide a commit message with at least a header line"]
541
+ )
542
+ );
545
543
  }
544
+ const matchResult = parseConventionalHeader(message, headerLine);
545
+ if (!matchResult.ok) return matchResult;
546
+ const match = matchResult.value;
547
+ const type = match[1];
548
+ const scope = match[3];
549
+ const breaking = match[4] === "!";
550
+ const description = match[5];
551
+ const issues = collectCommitIssues(type, description);
546
552
  if (issues.length > 0) {
547
- let errorMessage = `Commit message validation failed: ${issues.join("; ")}`;
548
- if (issues.some((issue) => issue.includes("description cannot be empty"))) {
549
- errorMessage = `Commit message validation failed: ${issues.join("; ")}`;
550
- }
551
- const error = createError(
552
- "VALIDATION_FAILED",
553
- errorMessage,
554
- { message, issues, type, scope },
555
- ["Review and fix the validation issues above"]
553
+ return (0, import_types.Err)(
554
+ createError(
555
+ "VALIDATION_FAILED",
556
+ `Commit message validation failed: ${issues.join("; ")}`,
557
+ { message, issues, type, scope },
558
+ ["Review and fix the validation issues above"]
559
+ )
556
560
  );
557
- return (0, import_types.Err)(error);
558
561
  }
559
- const result = {
562
+ return (0, import_types.Ok)({
560
563
  valid: true,
561
564
  type,
562
565
  ...scope && { scope },
563
- breaking: hasBreakingChange,
566
+ breaking: breaking || hasBreakingChangeInBody(lines),
564
567
  issues: []
565
- };
566
- return (0, import_types.Ok)(result);
568
+ });
567
569
  }
568
570
 
569
571
  // src/context/types.ts
@@ -1021,6 +1023,47 @@ var NODE_TYPE_TO_CATEGORY = {
1021
1023
  prompt: "systemPrompt",
1022
1024
  system: "systemPrompt"
1023
1025
  };
1026
+ function makeZeroWeights() {
1027
+ return {
1028
+ systemPrompt: 0,
1029
+ projectManifest: 0,
1030
+ taskSpec: 0,
1031
+ activeCode: 0,
1032
+ interfaces: 0,
1033
+ reserve: 0
1034
+ };
1035
+ }
1036
+ function normalizeRatios(ratios) {
1037
+ const sum = Object.values(ratios).reduce((s, r) => s + r, 0);
1038
+ if (sum === 0) return;
1039
+ for (const key of Object.keys(ratios)) {
1040
+ ratios[key] = ratios[key] / sum;
1041
+ }
1042
+ }
1043
+ function enforceMinimumRatios(ratios, min) {
1044
+ for (const key of Object.keys(ratios)) {
1045
+ if (ratios[key] < min) ratios[key] = min;
1046
+ }
1047
+ }
1048
+ function applyGraphDensity(ratios, graphDensity) {
1049
+ const weights = makeZeroWeights();
1050
+ for (const [nodeType, count] of Object.entries(graphDensity)) {
1051
+ const category = NODE_TYPE_TO_CATEGORY[nodeType];
1052
+ if (category) weights[category] += count;
1053
+ }
1054
+ const totalWeight = Object.values(weights).reduce((s, w) => s + w, 0);
1055
+ if (totalWeight === 0) return;
1056
+ const MIN = 0.01;
1057
+ for (const key of Object.keys(ratios)) {
1058
+ ratios[key] = weights[key] > 0 ? weights[key] / totalWeight : MIN;
1059
+ }
1060
+ if (ratios.reserve < DEFAULT_RATIOS.reserve) ratios.reserve = DEFAULT_RATIOS.reserve;
1061
+ if (ratios.systemPrompt < DEFAULT_RATIOS.systemPrompt)
1062
+ ratios.systemPrompt = DEFAULT_RATIOS.systemPrompt;
1063
+ normalizeRatios(ratios);
1064
+ enforceMinimumRatios(ratios, MIN);
1065
+ normalizeRatios(ratios);
1066
+ }
1024
1067
  function contextBudget(totalTokens, overrides, graphDensity) {
1025
1068
  const ratios = {
1026
1069
  systemPrompt: DEFAULT_RATIOS.systemPrompt,
@@ -1031,50 +1074,7 @@ function contextBudget(totalTokens, overrides, graphDensity) {
1031
1074
  reserve: DEFAULT_RATIOS.reserve
1032
1075
  };
1033
1076
  if (graphDensity) {
1034
- const categoryWeights = {
1035
- systemPrompt: 0,
1036
- projectManifest: 0,
1037
- taskSpec: 0,
1038
- activeCode: 0,
1039
- interfaces: 0,
1040
- reserve: 0
1041
- };
1042
- for (const [nodeType, count] of Object.entries(graphDensity)) {
1043
- const category = NODE_TYPE_TO_CATEGORY[nodeType];
1044
- if (category) {
1045
- categoryWeights[category] += count;
1046
- }
1047
- }
1048
- const totalWeight = Object.values(categoryWeights).reduce((sum, w) => sum + w, 0);
1049
- if (totalWeight > 0) {
1050
- const MIN_ALLOCATION = 0.01;
1051
- for (const key of Object.keys(ratios)) {
1052
- if (categoryWeights[key] > 0) {
1053
- ratios[key] = categoryWeights[key] / totalWeight;
1054
- } else {
1055
- ratios[key] = MIN_ALLOCATION;
1056
- }
1057
- }
1058
- if (ratios.reserve < DEFAULT_RATIOS.reserve) {
1059
- ratios.reserve = DEFAULT_RATIOS.reserve;
1060
- }
1061
- if (ratios.systemPrompt < DEFAULT_RATIOS.systemPrompt) {
1062
- ratios.systemPrompt = DEFAULT_RATIOS.systemPrompt;
1063
- }
1064
- const ratioSum = Object.values(ratios).reduce((sum, r) => sum + r, 0);
1065
- for (const key of Object.keys(ratios)) {
1066
- ratios[key] = ratios[key] / ratioSum;
1067
- }
1068
- for (const key of Object.keys(ratios)) {
1069
- if (ratios[key] < MIN_ALLOCATION) {
1070
- ratios[key] = MIN_ALLOCATION;
1071
- }
1072
- }
1073
- const finalSum = Object.values(ratios).reduce((sum, r) => sum + r, 0);
1074
- for (const key of Object.keys(ratios)) {
1075
- ratios[key] = ratios[key] / finalSum;
1076
- }
1077
- }
1077
+ applyGraphDensity(ratios, graphDensity);
1078
1078
  }
1079
1079
  if (overrides) {
1080
1080
  let overrideSum = 0;
@@ -1635,21 +1635,23 @@ function extractBundle(manifest, config) {
1635
1635
  }
1636
1636
 
1637
1637
  // src/constraints/sharing/merge.ts
1638
+ function arraysEqual(a, b) {
1639
+ if (a.length !== b.length) return false;
1640
+ return a.every((val, i) => deepEqual(val, b[i]));
1641
+ }
1642
+ function objectsEqual(a, b) {
1643
+ const keysA = Object.keys(a);
1644
+ const keysB = Object.keys(b);
1645
+ if (keysA.length !== keysB.length) return false;
1646
+ return keysA.every((key) => deepEqual(a[key], b[key]));
1647
+ }
1638
1648
  function deepEqual(a, b) {
1639
1649
  if (a === b) return true;
1640
1650
  if (typeof a !== typeof b) return false;
1641
1651
  if (typeof a !== "object" || a === null || b === null) return false;
1642
- if (Array.isArray(a) && Array.isArray(b)) {
1643
- if (a.length !== b.length) return false;
1644
- return a.every((val, i) => deepEqual(val, b[i]));
1645
- }
1652
+ if (Array.isArray(a) && Array.isArray(b)) return arraysEqual(a, b);
1646
1653
  if (Array.isArray(a) !== Array.isArray(b)) return false;
1647
- const keysA = Object.keys(a);
1648
- const keysB = Object.keys(b);
1649
- if (keysA.length !== keysB.length) return false;
1650
- return keysA.every(
1651
- (key) => deepEqual(a[key], b[key])
1652
- );
1654
+ return objectsEqual(a, b);
1653
1655
  }
1654
1656
  function stringArraysEqual(a, b) {
1655
1657
  if (a.length !== b.length) return false;
@@ -2302,17 +2304,22 @@ async function parseDocumentationFile(path31) {
2302
2304
  function makeInternalSymbol(name, type, line) {
2303
2305
  return { name, type, line, references: 0, calledBy: [] };
2304
2306
  }
2307
+ function extractFunctionSymbol(node, line) {
2308
+ if (node.id?.name) return [makeInternalSymbol(node.id.name, "function", line)];
2309
+ return [];
2310
+ }
2311
+ function extractVariableSymbols(node, line) {
2312
+ return (node.declarations || []).filter((decl) => decl.id?.name).map((decl) => makeInternalSymbol(decl.id.name, "variable", line));
2313
+ }
2314
+ function extractClassSymbol(node, line) {
2315
+ if (node.id?.name) return [makeInternalSymbol(node.id.name, "class", line)];
2316
+ return [];
2317
+ }
2305
2318
  function extractSymbolsFromNode(node) {
2306
2319
  const line = node.loc?.start?.line || 0;
2307
- if (node.type === "FunctionDeclaration" && node.id?.name) {
2308
- return [makeInternalSymbol(node.id.name, "function", line)];
2309
- }
2310
- if (node.type === "VariableDeclaration") {
2311
- return (node.declarations || []).filter((decl) => decl.id?.name).map((decl) => makeInternalSymbol(decl.id.name, "variable", line));
2312
- }
2313
- if (node.type === "ClassDeclaration" && node.id?.name) {
2314
- return [makeInternalSymbol(node.id.name, "class", line)];
2315
- }
2320
+ if (node.type === "FunctionDeclaration") return extractFunctionSymbol(node, line);
2321
+ if (node.type === "VariableDeclaration") return extractVariableSymbols(node, line);
2322
+ if (node.type === "ClassDeclaration") return extractClassSymbol(node, line);
2316
2323
  return [];
2317
2324
  }
2318
2325
  function extractInternalSymbols(ast) {
@@ -2321,21 +2328,17 @@ function extractInternalSymbols(ast) {
2321
2328
  const nodes = body.body;
2322
2329
  return nodes.flatMap(extractSymbolsFromNode);
2323
2330
  }
2331
+ function toJSDocComment(comment) {
2332
+ if (comment.type !== "Block" || !comment.value?.startsWith("*")) return null;
2333
+ return { content: comment.value, line: comment.loc?.start?.line || 0 };
2334
+ }
2324
2335
  function extractJSDocComments(ast) {
2325
- const comments = [];
2326
2336
  const body = ast.body;
2327
- if (body?.comments) {
2328
- for (const comment of body.comments) {
2329
- if (comment.type === "Block" && comment.value?.startsWith("*")) {
2330
- const jsDocComment = {
2331
- content: comment.value,
2332
- line: comment.loc?.start?.line || 0
2333
- };
2334
- comments.push(jsDocComment);
2335
- }
2336
- }
2337
- }
2338
- return comments;
2337
+ if (!body?.comments) return [];
2338
+ return body.comments.flatMap((c) => {
2339
+ const doc = toJSDocComment(c);
2340
+ return doc ? [doc] : [];
2341
+ });
2339
2342
  }
2340
2343
  function buildExportMap(files) {
2341
2344
  const byFile = /* @__PURE__ */ new Map();
@@ -2350,41 +2353,42 @@ function buildExportMap(files) {
2350
2353
  }
2351
2354
  return { byFile, byName };
2352
2355
  }
2353
- function extractAllCodeReferences(docs) {
2356
+ var CODE_BLOCK_LANGUAGES = /* @__PURE__ */ new Set(["typescript", "ts", "javascript", "js"]);
2357
+ function refsFromInlineRefs(doc) {
2358
+ return doc.inlineRefs.map((inlineRef) => ({
2359
+ docFile: doc.path,
2360
+ line: inlineRef.line,
2361
+ column: inlineRef.column,
2362
+ reference: inlineRef.reference,
2363
+ context: "inline"
2364
+ }));
2365
+ }
2366
+ function refsFromCodeBlock(docPath, block) {
2367
+ if (!CODE_BLOCK_LANGUAGES.has(block.language)) return [];
2354
2368
  const refs = [];
2355
- for (const doc of docs) {
2356
- for (const inlineRef of doc.inlineRefs) {
2369
+ const importRegex = /import\s+\{([^}]+)\}\s+from/g;
2370
+ let match;
2371
+ while ((match = importRegex.exec(block.content)) !== null) {
2372
+ const group = match[1];
2373
+ if (group === void 0) continue;
2374
+ for (const name of group.split(",").map((n) => n.trim())) {
2357
2375
  refs.push({
2358
- docFile: doc.path,
2359
- line: inlineRef.line,
2360
- column: inlineRef.column,
2361
- reference: inlineRef.reference,
2362
- context: "inline"
2376
+ docFile: docPath,
2377
+ line: block.line,
2378
+ column: 0,
2379
+ reference: name,
2380
+ context: "code-block"
2363
2381
  });
2364
2382
  }
2365
- for (const block of doc.codeBlocks) {
2366
- if (block.language === "typescript" || block.language === "ts" || block.language === "javascript" || block.language === "js") {
2367
- const importRegex = /import\s+\{([^}]+)\}\s+from/g;
2368
- let match;
2369
- while ((match = importRegex.exec(block.content)) !== null) {
2370
- const matchedGroup = match[1];
2371
- if (matchedGroup === void 0) continue;
2372
- const names = matchedGroup.split(",").map((n) => n.trim());
2373
- for (const name of names) {
2374
- refs.push({
2375
- docFile: doc.path,
2376
- line: block.line,
2377
- column: 0,
2378
- reference: name,
2379
- context: "code-block"
2380
- });
2381
- }
2382
- }
2383
- }
2384
- }
2385
2383
  }
2386
2384
  return refs;
2387
2385
  }
2386
+ function refsFromCodeBlocks(doc) {
2387
+ return doc.codeBlocks.flatMap((block) => refsFromCodeBlock(doc.path, block));
2388
+ }
2389
+ function extractAllCodeReferences(docs) {
2390
+ return docs.flatMap((doc) => [...refsFromInlineRefs(doc), ...refsFromCodeBlocks(doc)]);
2391
+ }
2388
2392
  async function buildSnapshot(config) {
2389
2393
  const startTime = Date.now();
2390
2394
  const parser = config.parser || new TypeScriptParser();
@@ -2590,44 +2594,52 @@ async function checkStructureDrift(snapshot, _config) {
2590
2594
  }
2591
2595
  return drifts;
2592
2596
  }
2597
+ function computeDriftSeverity(driftCount) {
2598
+ if (driftCount === 0) return "none";
2599
+ if (driftCount <= 3) return "low";
2600
+ if (driftCount <= 10) return "medium";
2601
+ return "high";
2602
+ }
2603
+ function buildGraphDriftReport(graphDriftData) {
2604
+ const drifts = [];
2605
+ for (const target of graphDriftData.missingTargets) {
2606
+ drifts.push({
2607
+ type: "api-signature",
2608
+ docFile: target,
2609
+ line: 0,
2610
+ reference: target,
2611
+ context: "graph-missing-target",
2612
+ issue: "NOT_FOUND",
2613
+ details: `Graph node "${target}" has no matching code target`,
2614
+ confidence: "high"
2615
+ });
2616
+ }
2617
+ for (const edge of graphDriftData.staleEdges) {
2618
+ drifts.push({
2619
+ type: "api-signature",
2620
+ docFile: edge.docNodeId,
2621
+ line: 0,
2622
+ reference: edge.codeNodeId,
2623
+ context: `graph-stale-edge:${edge.edgeType}`,
2624
+ issue: "NOT_FOUND",
2625
+ details: `Stale edge from doc "${edge.docNodeId}" to code "${edge.codeNodeId}" (${edge.edgeType})`,
2626
+ confidence: "medium"
2627
+ });
2628
+ }
2629
+ return (0, import_types.Ok)({
2630
+ drifts,
2631
+ stats: {
2632
+ docsScanned: graphDriftData.staleEdges.length,
2633
+ referencesChecked: graphDriftData.staleEdges.length + graphDriftData.missingTargets.length,
2634
+ driftsFound: drifts.length,
2635
+ byType: { api: drifts.length, example: 0, structure: 0 }
2636
+ },
2637
+ severity: computeDriftSeverity(drifts.length)
2638
+ });
2639
+ }
2593
2640
  async function detectDocDrift(snapshot, config, graphDriftData) {
2594
2641
  if (graphDriftData) {
2595
- const drifts2 = [];
2596
- for (const target of graphDriftData.missingTargets) {
2597
- drifts2.push({
2598
- type: "api-signature",
2599
- docFile: target,
2600
- line: 0,
2601
- reference: target,
2602
- context: "graph-missing-target",
2603
- issue: "NOT_FOUND",
2604
- details: `Graph node "${target}" has no matching code target`,
2605
- confidence: "high"
2606
- });
2607
- }
2608
- for (const edge of graphDriftData.staleEdges) {
2609
- drifts2.push({
2610
- type: "api-signature",
2611
- docFile: edge.docNodeId,
2612
- line: 0,
2613
- reference: edge.codeNodeId,
2614
- context: `graph-stale-edge:${edge.edgeType}`,
2615
- issue: "NOT_FOUND",
2616
- details: `Stale edge from doc "${edge.docNodeId}" to code "${edge.codeNodeId}" (${edge.edgeType})`,
2617
- confidence: "medium"
2618
- });
2619
- }
2620
- const severity2 = drifts2.length === 0 ? "none" : drifts2.length <= 3 ? "low" : drifts2.length <= 10 ? "medium" : "high";
2621
- return (0, import_types.Ok)({
2622
- drifts: drifts2,
2623
- stats: {
2624
- docsScanned: graphDriftData.staleEdges.length,
2625
- referencesChecked: graphDriftData.staleEdges.length + graphDriftData.missingTargets.length,
2626
- driftsFound: drifts2.length,
2627
- byType: { api: drifts2.length, example: 0, structure: 0 }
2628
- },
2629
- severity: severity2
2630
- });
2642
+ return buildGraphDriftReport(graphDriftData);
2631
2643
  }
2632
2644
  const fullConfig = { ...DEFAULT_DRIFT_CONFIG, ...config };
2633
2645
  const drifts = [];
@@ -2676,6 +2688,23 @@ function resolveImportToFile(importSource, fromFile, snapshot) {
2676
2688
  }
2677
2689
  return null;
2678
2690
  }
2691
+ function enqueueResolved(sources, current, snapshot, visited, queue) {
2692
+ for (const item of sources) {
2693
+ if (!item.source) continue;
2694
+ const resolved = resolveImportToFile(item.source, current, snapshot);
2695
+ if (resolved && !visited.has(resolved)) {
2696
+ queue.push(resolved);
2697
+ }
2698
+ }
2699
+ }
2700
+ function processReachabilityNode(current, snapshot, reachability, visited, queue) {
2701
+ reachability.set(current, true);
2702
+ const sourceFile = snapshot.files.find((f) => f.path === current);
2703
+ if (!sourceFile) return;
2704
+ enqueueResolved(sourceFile.imports, current, snapshot, visited, queue);
2705
+ const reExports = sourceFile.exports.filter((e) => e.isReExport);
2706
+ enqueueResolved(reExports, current, snapshot, visited, queue);
2707
+ }
2679
2708
  function buildReachabilityMap(snapshot) {
2680
2709
  const reachability = /* @__PURE__ */ new Map();
2681
2710
  for (const file of snapshot.files) {
@@ -2687,23 +2716,7 @@ function buildReachabilityMap(snapshot) {
2687
2716
  const current = queue.shift();
2688
2717
  if (visited.has(current)) continue;
2689
2718
  visited.add(current);
2690
- reachability.set(current, true);
2691
- const sourceFile = snapshot.files.find((f) => f.path === current);
2692
- if (!sourceFile) continue;
2693
- for (const imp of sourceFile.imports) {
2694
- const resolved = resolveImportToFile(imp.source, current, snapshot);
2695
- if (resolved && !visited.has(resolved)) {
2696
- queue.push(resolved);
2697
- }
2698
- }
2699
- for (const exp of sourceFile.exports) {
2700
- if (exp.isReExport && exp.source) {
2701
- const resolved = resolveImportToFile(exp.source, current, snapshot);
2702
- if (resolved && !visited.has(resolved)) {
2703
- queue.push(resolved);
2704
- }
2705
- }
2706
- }
2719
+ processReachabilityNode(current, snapshot, reachability, visited, queue);
2707
2720
  }
2708
2721
  return reachability;
2709
2722
  }
@@ -2773,21 +2786,27 @@ function findDeadExports(snapshot, usageMap, reachability) {
2773
2786
  }
2774
2787
  return deadExports;
2775
2788
  }
2789
+ function maxLineOfValue(value) {
2790
+ if (Array.isArray(value)) {
2791
+ return value.reduce((m, item) => Math.max(m, findMaxLineInNode(item)), 0);
2792
+ }
2793
+ if (value && typeof value === "object") {
2794
+ return findMaxLineInNode(value);
2795
+ }
2796
+ return 0;
2797
+ }
2798
+ function maxLineOfNodeKeys(node) {
2799
+ let max = 0;
2800
+ for (const key of Object.keys(node)) {
2801
+ max = Math.max(max, maxLineOfValue(node[key]));
2802
+ }
2803
+ return max;
2804
+ }
2776
2805
  function findMaxLineInNode(node) {
2777
2806
  if (!node || typeof node !== "object") return 0;
2778
2807
  const n = node;
2779
- let maxLine = n.loc?.end?.line ?? 0;
2780
- for (const key of Object.keys(node)) {
2781
- const value = node[key];
2782
- if (Array.isArray(value)) {
2783
- for (const item of value) {
2784
- maxLine = Math.max(maxLine, findMaxLineInNode(item));
2785
- }
2786
- } else if (value && typeof value === "object") {
2787
- maxLine = Math.max(maxLine, findMaxLineInNode(value));
2788
- }
2789
- }
2790
- return maxLine;
2808
+ const locLine = n.loc?.end?.line ?? 0;
2809
+ return Math.max(locLine, maxLineOfNodeKeys(node));
2791
2810
  }
2792
2811
  function countLinesFromAST(ast) {
2793
2812
  if (!ast.body || !Array.isArray(ast.body)) return 1;
@@ -2861,54 +2880,59 @@ function findDeadInternals(snapshot, _reachability) {
2861
2880
  }
2862
2881
  return deadInternals;
2863
2882
  }
2864
- async function detectDeadCode(snapshot, graphDeadCodeData) {
2865
- if (graphDeadCodeData) {
2866
- const deadFiles2 = [];
2867
- const deadExports2 = [];
2868
- const fileTypes = /* @__PURE__ */ new Set(["file", "module"]);
2869
- const exportTypes = /* @__PURE__ */ new Set(["function", "class", "method", "interface", "variable"]);
2870
- for (const node of graphDeadCodeData.unreachableNodes) {
2871
- if (fileTypes.has(node.type)) {
2872
- deadFiles2.push({
2873
- path: node.path || node.id,
2874
- reason: "NO_IMPORTERS",
2875
- exportCount: 0,
2876
- lineCount: 0
2877
- });
2878
- } else if (exportTypes.has(node.type)) {
2879
- const exportType = node.type === "method" ? "function" : node.type;
2880
- deadExports2.push({
2881
- file: node.path || node.id,
2882
- name: node.name,
2883
- line: 0,
2884
- type: exportType,
2885
- isDefault: false,
2886
- reason: "NO_IMPORTERS"
2887
- });
2888
- }
2889
- }
2890
- const reachableCount = graphDeadCodeData.reachableNodeIds instanceof Set ? graphDeadCodeData.reachableNodeIds.size : graphDeadCodeData.reachableNodeIds.length;
2891
- const fileNodes = graphDeadCodeData.unreachableNodes.filter((n) => fileTypes.has(n.type));
2892
- const exportNodes = graphDeadCodeData.unreachableNodes.filter((n) => exportTypes.has(n.type));
2893
- const totalFiles = reachableCount + fileNodes.length;
2894
- const totalExports2 = exportNodes.length + (reachableCount > 0 ? reachableCount : 0);
2895
- const report2 = {
2896
- deadExports: deadExports2,
2897
- deadFiles: deadFiles2,
2898
- deadInternals: [],
2899
- unusedImports: [],
2900
- stats: {
2901
- filesAnalyzed: totalFiles,
2902
- entryPointsUsed: [],
2903
- totalExports: totalExports2,
2904
- deadExportCount: deadExports2.length,
2905
- totalFiles,
2906
- deadFileCount: deadFiles2.length,
2907
- estimatedDeadLines: 0
2908
- }
2909
- };
2910
- return (0, import_types.Ok)(report2);
2883
+ var FILE_TYPES = /* @__PURE__ */ new Set(["file", "module"]);
2884
+ var EXPORT_TYPES = /* @__PURE__ */ new Set(["function", "class", "method", "interface", "variable"]);
2885
+ function classifyUnreachableNode(node, deadFiles, deadExports) {
2886
+ if (FILE_TYPES.has(node.type)) {
2887
+ deadFiles.push({
2888
+ path: node.path || node.id,
2889
+ reason: "NO_IMPORTERS",
2890
+ exportCount: 0,
2891
+ lineCount: 0
2892
+ });
2893
+ } else if (EXPORT_TYPES.has(node.type)) {
2894
+ const exportType = node.type === "method" ? "function" : node.type;
2895
+ deadExports.push({
2896
+ file: node.path || node.id,
2897
+ name: node.name,
2898
+ line: 0,
2899
+ type: exportType,
2900
+ isDefault: false,
2901
+ reason: "NO_IMPORTERS"
2902
+ });
2911
2903
  }
2904
+ }
2905
+ function computeGraphReportStats(data, deadFiles, deadExports) {
2906
+ const reachableCount = data.reachableNodeIds instanceof Set ? data.reachableNodeIds.size : data.reachableNodeIds.length;
2907
+ const fileNodes = data.unreachableNodes.filter((n) => FILE_TYPES.has(n.type));
2908
+ const exportNodes = data.unreachableNodes.filter((n) => EXPORT_TYPES.has(n.type));
2909
+ const totalFiles = reachableCount + fileNodes.length;
2910
+ const totalExports = exportNodes.length + (reachableCount > 0 ? reachableCount : 0);
2911
+ return {
2912
+ filesAnalyzed: totalFiles,
2913
+ entryPointsUsed: [],
2914
+ totalExports,
2915
+ deadExportCount: deadExports.length,
2916
+ totalFiles,
2917
+ deadFileCount: deadFiles.length,
2918
+ estimatedDeadLines: 0
2919
+ };
2920
+ }
2921
+ function buildReportFromGraph(data) {
2922
+ const deadFiles = [];
2923
+ const deadExports = [];
2924
+ for (const node of data.unreachableNodes) {
2925
+ classifyUnreachableNode(node, deadFiles, deadExports);
2926
+ }
2927
+ return {
2928
+ deadExports,
2929
+ deadFiles,
2930
+ deadInternals: [],
2931
+ unusedImports: [],
2932
+ stats: computeGraphReportStats(data, deadFiles, deadExports)
2933
+ };
2934
+ }
2935
+ function buildReportFromSnapshot(snapshot) {
2912
2936
  const reachability = buildReachabilityMap(snapshot);
2913
2937
  const usageMap = buildExportUsageMap(snapshot);
2914
2938
  const deadExports = findDeadExports(snapshot, usageMap, reachability);
@@ -2920,7 +2944,7 @@ async function detectDeadCode(snapshot, graphDeadCodeData) {
2920
2944
  0
2921
2945
  );
2922
2946
  const estimatedDeadLines = deadFiles.reduce((acc, file) => acc + file.lineCount, 0);
2923
- const report = {
2947
+ return {
2924
2948
  deadExports,
2925
2949
  deadFiles,
2926
2950
  deadInternals,
@@ -2935,6 +2959,9 @@ async function detectDeadCode(snapshot, graphDeadCodeData) {
2935
2959
  estimatedDeadLines
2936
2960
  }
2937
2961
  };
2962
+ }
2963
+ async function detectDeadCode(snapshot, graphDeadCodeData) {
2964
+ const report = graphDeadCodeData ? buildReportFromGraph(graphDeadCodeData) : buildReportFromSnapshot(snapshot);
2938
2965
  return (0, import_types.Ok)(report);
2939
2966
  }
2940
2967
 
@@ -3205,26 +3232,28 @@ function findFunctionEnd(lines, startIdx) {
3205
3232
  }
3206
3233
  return lines.length - 1;
3207
3234
  }
3208
- function computeCyclomaticComplexity(body) {
3209
- let complexity = 1;
3210
- const decisionPatterns = [
3211
- /\bif\s*\(/g,
3212
- /\belse\s+if\s*\(/g,
3213
- /\bwhile\s*\(/g,
3214
- /\bfor\s*\(/g,
3215
- /\bcase\s+/g,
3216
- /&&/g,
3217
- /\|\|/g,
3218
- /\?(?!=)/g,
3219
- // Ternary ? but not ?. or ??
3220
- /\bcatch\s*\(/g
3221
- ];
3222
- for (const pattern of decisionPatterns) {
3235
+ var DECISION_PATTERNS = [
3236
+ /\bif\s*\(/g,
3237
+ /\belse\s+if\s*\(/g,
3238
+ /\bwhile\s*\(/g,
3239
+ /\bfor\s*\(/g,
3240
+ /\bcase\s+/g,
3241
+ /&&/g,
3242
+ /\|\|/g,
3243
+ /\?(?!=)/g,
3244
+ // Ternary ? but not ?. or ??
3245
+ /\bcatch\s*\(/g
3246
+ ];
3247
+ function countDecisionPoints(body) {
3248
+ let count = 0;
3249
+ for (const pattern of DECISION_PATTERNS) {
3223
3250
  const matches = body.match(pattern);
3224
- if (matches) {
3225
- complexity += matches.length;
3226
- }
3251
+ if (matches) count += matches.length;
3227
3252
  }
3253
+ return count;
3254
+ }
3255
+ function computeCyclomaticComplexity(body) {
3256
+ let complexity = 1 + countDecisionPoints(body);
3228
3257
  const elseIfMatches = body.match(/\belse\s+if\s*\(/g);
3229
3258
  if (elseIfMatches) {
3230
3259
  complexity -= elseIfMatches.length;
@@ -3502,8 +3531,8 @@ function resolveImportSource(source, fromFile, snapshot) {
3502
3531
  }
3503
3532
  return void 0;
3504
3533
  }
3505
- function checkViolations(metrics, config) {
3506
- const thresholds = {
3534
+ function resolveThresholds2(config) {
3535
+ return {
3507
3536
  fanOut: { ...DEFAULT_THRESHOLDS2.fanOut, ...config?.thresholds?.fanOut },
3508
3537
  fanIn: { ...DEFAULT_THRESHOLDS2.fanIn, ...config?.thresholds?.fanIn },
3509
3538
  couplingRatio: { ...DEFAULT_THRESHOLDS2.couplingRatio, ...config?.thresholds?.couplingRatio },
@@ -3512,53 +3541,70 @@ function checkViolations(metrics, config) {
3512
3541
  ...config?.thresholds?.transitiveDependencyDepth
3513
3542
  }
3514
3543
  };
3544
+ }
3545
+ function checkFanOut(m, threshold) {
3546
+ if (m.fanOut <= threshold) return null;
3547
+ return {
3548
+ file: m.file,
3549
+ metric: "fanOut",
3550
+ value: m.fanOut,
3551
+ threshold,
3552
+ tier: 2,
3553
+ severity: "warning",
3554
+ message: `File has ${m.fanOut} imports (threshold: ${threshold})`
3555
+ };
3556
+ }
3557
+ function checkFanIn(m, threshold) {
3558
+ if (m.fanIn <= threshold) return null;
3559
+ return {
3560
+ file: m.file,
3561
+ metric: "fanIn",
3562
+ value: m.fanIn,
3563
+ threshold,
3564
+ tier: 3,
3565
+ severity: "info",
3566
+ message: `File is imported by ${m.fanIn} files (threshold: ${threshold})`
3567
+ };
3568
+ }
3569
+ function checkCouplingRatio(m, threshold) {
3570
+ const totalConnections = m.fanIn + m.fanOut;
3571
+ if (totalConnections <= 5 || m.couplingRatio <= threshold) return null;
3572
+ return {
3573
+ file: m.file,
3574
+ metric: "couplingRatio",
3575
+ value: m.couplingRatio,
3576
+ threshold,
3577
+ tier: 2,
3578
+ severity: "warning",
3579
+ message: `Coupling ratio is ${m.couplingRatio.toFixed(2)} (threshold: ${threshold})`
3580
+ };
3581
+ }
3582
+ function checkTransitiveDepth(m, threshold) {
3583
+ if (m.transitiveDepth <= threshold) return null;
3584
+ return {
3585
+ file: m.file,
3586
+ metric: "transitiveDependencyDepth",
3587
+ value: m.transitiveDepth,
3588
+ threshold,
3589
+ tier: 3,
3590
+ severity: "info",
3591
+ message: `Transitive dependency depth is ${m.transitiveDepth} (threshold: ${threshold})`
3592
+ };
3593
+ }
3594
+ function checkMetricViolations(m, thresholds) {
3595
+ const candidates = [
3596
+ checkFanOut(m, thresholds.fanOut.warn),
3597
+ checkFanIn(m, thresholds.fanIn.info),
3598
+ checkCouplingRatio(m, thresholds.couplingRatio.warn),
3599
+ checkTransitiveDepth(m, thresholds.transitiveDependencyDepth.info)
3600
+ ];
3601
+ return candidates.filter((v) => v !== null);
3602
+ }
3603
+ function checkViolations(metrics, config) {
3604
+ const thresholds = resolveThresholds2(config);
3515
3605
  const violations = [];
3516
3606
  for (const m of metrics) {
3517
- if (thresholds.fanOut.warn !== void 0 && m.fanOut > thresholds.fanOut.warn) {
3518
- violations.push({
3519
- file: m.file,
3520
- metric: "fanOut",
3521
- value: m.fanOut,
3522
- threshold: thresholds.fanOut.warn,
3523
- tier: 2,
3524
- severity: "warning",
3525
- message: `File has ${m.fanOut} imports (threshold: ${thresholds.fanOut.warn})`
3526
- });
3527
- }
3528
- if (thresholds.fanIn.info !== void 0 && m.fanIn > thresholds.fanIn.info) {
3529
- violations.push({
3530
- file: m.file,
3531
- metric: "fanIn",
3532
- value: m.fanIn,
3533
- threshold: thresholds.fanIn.info,
3534
- tier: 3,
3535
- severity: "info",
3536
- message: `File is imported by ${m.fanIn} files (threshold: ${thresholds.fanIn.info})`
3537
- });
3538
- }
3539
- const totalConnections = m.fanIn + m.fanOut;
3540
- if (totalConnections > 5 && thresholds.couplingRatio.warn !== void 0 && m.couplingRatio > thresholds.couplingRatio.warn) {
3541
- violations.push({
3542
- file: m.file,
3543
- metric: "couplingRatio",
3544
- value: m.couplingRatio,
3545
- threshold: thresholds.couplingRatio.warn,
3546
- tier: 2,
3547
- severity: "warning",
3548
- message: `Coupling ratio is ${m.couplingRatio.toFixed(2)} (threshold: ${thresholds.couplingRatio.warn})`
3549
- });
3550
- }
3551
- if (thresholds.transitiveDependencyDepth.info !== void 0 && m.transitiveDepth > thresholds.transitiveDependencyDepth.info) {
3552
- violations.push({
3553
- file: m.file,
3554
- metric: "transitiveDependencyDepth",
3555
- value: m.transitiveDepth,
3556
- threshold: thresholds.transitiveDependencyDepth.info,
3557
- tier: 3,
3558
- severity: "info",
3559
- message: `Transitive dependency depth is ${m.transitiveDepth} (threshold: ${thresholds.transitiveDependencyDepth.info})`
3560
- });
3561
- }
3607
+ violations.push(...checkMetricViolations(m, thresholds));
3562
3608
  }
3563
3609
  return violations;
3564
3610
  }
@@ -3668,48 +3714,52 @@ async function detectSizeBudgetViolations(rootDir, config) {
3668
3714
  }
3669
3715
 
3670
3716
  // src/entropy/fixers/suggestions.ts
3717
+ function deadFileSuggestion(file) {
3718
+ return {
3719
+ type: "delete",
3720
+ priority: "high",
3721
+ source: "dead-code",
3722
+ relatedIssues: [`dead-file:${file.path}`],
3723
+ title: `Remove dead file: ${file.path.split("/").pop()}`,
3724
+ description: `This file is not imported by any other file and can be safely removed.`,
3725
+ files: [file.path],
3726
+ steps: [`Delete ${file.path}`, "Run tests to verify no regressions"],
3727
+ whyManual: "File deletion requires verification that no dynamic imports exist"
3728
+ };
3729
+ }
3730
+ function deadExportSuggestion(exp) {
3731
+ return {
3732
+ type: "refactor",
3733
+ priority: "medium",
3734
+ source: "dead-code",
3735
+ relatedIssues: [`dead-export:${exp.file}:${exp.name}`],
3736
+ title: `Remove unused export: ${exp.name}`,
3737
+ description: `The export "${exp.name}" is not used anywhere. Consider removing it.`,
3738
+ files: [exp.file],
3739
+ steps: [`Remove export "${exp.name}" from ${exp.file}`, "Run tests to verify no regressions"],
3740
+ whyManual: "Export removal may affect external consumers not in scope"
3741
+ };
3742
+ }
3743
+ function unusedImportSuggestion(imp) {
3744
+ const plural = imp.specifiers.length > 1;
3745
+ return {
3746
+ type: "delete",
3747
+ priority: "medium",
3748
+ source: "dead-code",
3749
+ relatedIssues: [`unused-import:${imp.file}:${imp.specifiers.join(",")}`],
3750
+ title: `Remove unused import${plural ? "s" : ""}: ${imp.specifiers.join(", ")}`,
3751
+ description: `The import${plural ? "s" : ""} from "${imp.source}" ${plural ? "are" : "is"} not used.`,
3752
+ files: [imp.file],
3753
+ steps: imp.isFullyUnused ? [`Remove entire import line from ${imp.file}`] : [`Remove unused specifiers (${imp.specifiers.join(", ")}) from import statement`],
3754
+ whyManual: "Import removal can be auto-fixed"
3755
+ };
3756
+ }
3671
3757
  function generateDeadCodeSuggestions(report) {
3672
- const suggestions = [];
3673
- for (const file of report.deadFiles) {
3674
- suggestions.push({
3675
- type: "delete",
3676
- priority: "high",
3677
- source: "dead-code",
3678
- relatedIssues: [`dead-file:${file.path}`],
3679
- title: `Remove dead file: ${file.path.split("/").pop()}`,
3680
- description: `This file is not imported by any other file and can be safely removed.`,
3681
- files: [file.path],
3682
- steps: [`Delete ${file.path}`, "Run tests to verify no regressions"],
3683
- whyManual: "File deletion requires verification that no dynamic imports exist"
3684
- });
3685
- }
3686
- for (const exp of report.deadExports) {
3687
- suggestions.push({
3688
- type: "refactor",
3689
- priority: "medium",
3690
- source: "dead-code",
3691
- relatedIssues: [`dead-export:${exp.file}:${exp.name}`],
3692
- title: `Remove unused export: ${exp.name}`,
3693
- description: `The export "${exp.name}" is not used anywhere. Consider removing it.`,
3694
- files: [exp.file],
3695
- steps: [`Remove export "${exp.name}" from ${exp.file}`, "Run tests to verify no regressions"],
3696
- whyManual: "Export removal may affect external consumers not in scope"
3697
- });
3698
- }
3699
- for (const imp of report.unusedImports) {
3700
- suggestions.push({
3701
- type: "delete",
3702
- priority: "medium",
3703
- source: "dead-code",
3704
- relatedIssues: [`unused-import:${imp.file}:${imp.specifiers.join(",")}`],
3705
- title: `Remove unused import${imp.specifiers.length > 1 ? "s" : ""}: ${imp.specifiers.join(", ")}`,
3706
- description: `The import${imp.specifiers.length > 1 ? "s" : ""} from "${imp.source}" ${imp.specifiers.length > 1 ? "are" : "is"} not used.`,
3707
- files: [imp.file],
3708
- steps: imp.isFullyUnused ? [`Remove entire import line from ${imp.file}`] : [`Remove unused specifiers (${imp.specifiers.join(", ")}) from import statement`],
3709
- whyManual: "Import removal can be auto-fixed"
3710
- });
3711
- }
3712
- return suggestions;
3758
+ return [
3759
+ ...report.deadFiles.map(deadFileSuggestion),
3760
+ ...report.deadExports.map(deadExportSuggestion),
3761
+ ...report.unusedImports.map(unusedImportSuggestion)
3762
+ ];
3713
3763
  }
3714
3764
  function generateDriftSuggestions(report) {
3715
3765
  const suggestions = [];
@@ -4152,43 +4202,55 @@ async function createBackup(filePath, backupDir) {
4152
4202
  );
4153
4203
  }
4154
4204
  }
4155
- async function applySingleFix(fix, config) {
4205
+ async function applyDeleteFile(fix, config) {
4206
+ if (config.createBackup && config.backupDir) {
4207
+ const backupResult = await createBackup(fix.file, config.backupDir);
4208
+ if (!backupResult.ok) return (0, import_types.Err)({ fix, error: backupResult.error.message });
4209
+ }
4210
+ await unlink2(fix.file);
4211
+ return (0, import_types.Ok)(void 0);
4212
+ }
4213
+ async function applyDeleteLines(fix) {
4214
+ if (fix.line !== void 0) {
4215
+ const content = await readFile5(fix.file, "utf-8");
4216
+ const lines = content.split("\n");
4217
+ lines.splice(fix.line - 1, 1);
4218
+ await writeFile3(fix.file, lines.join("\n"));
4219
+ }
4220
+ }
4221
+ async function applyReplace(fix) {
4222
+ if (fix.oldContent && fix.newContent !== void 0) {
4223
+ const content = await readFile5(fix.file, "utf-8");
4224
+ await writeFile3(fix.file, content.replace(fix.oldContent, fix.newContent));
4225
+ }
4226
+ }
4227
+ async function applyInsert(fix) {
4228
+ if (fix.line !== void 0 && fix.newContent) {
4229
+ const content = await readFile5(fix.file, "utf-8");
4230
+ const lines = content.split("\n");
4231
+ lines.splice(fix.line - 1, 0, fix.newContent);
4232
+ await writeFile3(fix.file, lines.join("\n"));
4233
+ }
4234
+ }
4235
+ async function applySingleFix(fix, config) {
4156
4236
  if (config.dryRun) {
4157
4237
  return (0, import_types.Ok)(fix);
4158
4238
  }
4159
4239
  try {
4160
4240
  switch (fix.action) {
4161
- case "delete-file":
4162
- if (config.createBackup && config.backupDir) {
4163
- const backupResult = await createBackup(fix.file, config.backupDir);
4164
- if (!backupResult.ok) {
4165
- return (0, import_types.Err)({ fix, error: backupResult.error.message });
4166
- }
4167
- }
4168
- await unlink2(fix.file);
4241
+ case "delete-file": {
4242
+ const result = await applyDeleteFile(fix, config);
4243
+ if (!result.ok) return result;
4169
4244
  break;
4245
+ }
4170
4246
  case "delete-lines":
4171
- if (fix.line !== void 0) {
4172
- const content = await readFile5(fix.file, "utf-8");
4173
- const lines = content.split("\n");
4174
- lines.splice(fix.line - 1, 1);
4175
- await writeFile3(fix.file, lines.join("\n"));
4176
- }
4247
+ await applyDeleteLines(fix);
4177
4248
  break;
4178
4249
  case "replace":
4179
- if (fix.oldContent && fix.newContent !== void 0) {
4180
- const content = await readFile5(fix.file, "utf-8");
4181
- const newContent = content.replace(fix.oldContent, fix.newContent);
4182
- await writeFile3(fix.file, newContent);
4183
- }
4250
+ await applyReplace(fix);
4184
4251
  break;
4185
4252
  case "insert":
4186
- if (fix.line !== void 0 && fix.newContent) {
4187
- const content = await readFile5(fix.file, "utf-8");
4188
- const lines = content.split("\n");
4189
- lines.splice(fix.line - 1, 0, fix.newContent);
4190
- await writeFile3(fix.file, lines.join("\n"));
4191
- }
4253
+ await applyInsert(fix);
4192
4254
  break;
4193
4255
  }
4194
4256
  return (0, import_types.Ok)(fix);
@@ -4361,6 +4423,21 @@ function applyHotspotDowngrade(finding, hotspot) {
4361
4423
  }
4362
4424
  return finding;
4363
4425
  }
4426
+ function mergeGroup(group) {
4427
+ if (group.length === 1) return [group[0]];
4428
+ const deadCode = group.find((f) => f.concern === "dead-code");
4429
+ const arch = group.find((f) => f.concern === "architecture");
4430
+ if (deadCode && arch) {
4431
+ return [
4432
+ {
4433
+ ...deadCode,
4434
+ description: `${deadCode.description} (also violates architecture: ${arch.type})`,
4435
+ suggestion: deadCode.fixAction ? `${deadCode.fixAction} (resolves both dead code and architecture violation)` : deadCode.suggestion
4436
+ }
4437
+ ];
4438
+ }
4439
+ return group;
4440
+ }
4364
4441
  function deduplicateCleanupFindings(findings) {
4365
4442
  const byFileAndLine = /* @__PURE__ */ new Map();
4366
4443
  for (const f of findings) {
@@ -4371,21 +4448,7 @@ function deduplicateCleanupFindings(findings) {
4371
4448
  }
4372
4449
  const result = [];
4373
4450
  for (const group of byFileAndLine.values()) {
4374
- if (group.length === 1) {
4375
- result.push(group[0]);
4376
- continue;
4377
- }
4378
- const deadCode = group.find((f) => f.concern === "dead-code");
4379
- const arch = group.find((f) => f.concern === "architecture");
4380
- if (deadCode && arch) {
4381
- result.push({
4382
- ...deadCode,
4383
- description: `${deadCode.description} (also violates architecture: ${arch.type})`,
4384
- suggestion: deadCode.fixAction ? `${deadCode.fixAction} (resolves both dead code and architecture violation)` : deadCode.suggestion
4385
- });
4386
- } else {
4387
- result.push(...group);
4388
- }
4451
+ result.push(...mergeGroup(group));
4389
4452
  }
4390
4453
  return result;
4391
4454
  }
@@ -4758,6 +4821,32 @@ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", ".git"]);
4758
4821
  var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx"]);
4759
4822
  var FUNCTION_DECL_RE = /(?:export\s+)?(?:async\s+)?function\s+(\w+)/;
4760
4823
  var CONST_DECL_RE = /(?:export\s+)?(?:const|let)\s+(\w+)\s*=/;
4824
+ function mergeGraphInferred(highFanInFunctions, seen) {
4825
+ let added = 0;
4826
+ for (const item of highFanInFunctions) {
4827
+ const key = `${item.file}::${item.function}`;
4828
+ if (!seen.has(key)) {
4829
+ seen.set(key, {
4830
+ file: item.file,
4831
+ function: item.function,
4832
+ source: "graph-inferred",
4833
+ fanIn: item.fanIn
4834
+ });
4835
+ added++;
4836
+ }
4837
+ }
4838
+ return added;
4839
+ }
4840
+ function isCommentOrBlank(line) {
4841
+ return line === "" || line === "*/" || line === "*" || line.startsWith("*") || line.startsWith("//");
4842
+ }
4843
+ function matchDeclarationName(line) {
4844
+ const funcMatch = line.match(FUNCTION_DECL_RE);
4845
+ if (funcMatch?.[1]) return funcMatch[1];
4846
+ const constMatch = line.match(CONST_DECL_RE);
4847
+ if (constMatch?.[1]) return constMatch[1];
4848
+ return null;
4849
+ }
4761
4850
  var CriticalPathResolver = class {
4762
4851
  projectRoot;
4763
4852
  constructor(projectRoot) {
@@ -4770,27 +4859,12 @@ var CriticalPathResolver = class {
4770
4859
  const key = `${entry.file}::${entry.function}`;
4771
4860
  seen.set(key, entry);
4772
4861
  }
4773
- let graphInferred = 0;
4774
- if (graphData) {
4775
- for (const item of graphData.highFanInFunctions) {
4776
- const key = `${item.file}::${item.function}`;
4777
- if (!seen.has(key)) {
4778
- seen.set(key, {
4779
- file: item.file,
4780
- function: item.function,
4781
- source: "graph-inferred",
4782
- fanIn: item.fanIn
4783
- });
4784
- graphInferred++;
4785
- }
4786
- }
4787
- }
4862
+ const graphInferred = graphData ? mergeGraphInferred(graphData.highFanInFunctions, seen) : 0;
4788
4863
  const entries = Array.from(seen.values());
4789
- const annotatedCount = annotated.length;
4790
4864
  return {
4791
4865
  entries,
4792
4866
  stats: {
4793
- annotated: annotatedCount,
4867
+ annotated: annotated.length,
4794
4868
  graphInferred,
4795
4869
  total: entries.length
4796
4870
  }
@@ -4817,6 +4891,14 @@ var CriticalPathResolver = class {
4817
4891
  }
4818
4892
  }
4819
4893
  }
4894
+ resolveFunctionName(lines, fromIndex) {
4895
+ for (let j = fromIndex; j < lines.length; j++) {
4896
+ const nextLine = lines[j].trim();
4897
+ if (isCommentOrBlank(nextLine)) continue;
4898
+ return matchDeclarationName(nextLine);
4899
+ }
4900
+ return null;
4901
+ }
4820
4902
  scanFile(filePath, entries) {
4821
4903
  let content;
4822
4904
  try {
@@ -4827,30 +4909,10 @@ var CriticalPathResolver = class {
4827
4909
  const lines = content.split("\n");
4828
4910
  const relativePath = path.relative(this.projectRoot, filePath).replace(/\\/g, "/");
4829
4911
  for (let i = 0; i < lines.length; i++) {
4830
- const line = lines[i];
4831
- if (!line.includes("@perf-critical")) continue;
4832
- for (let j = i + 1; j < lines.length; j++) {
4833
- const nextLine = lines[j].trim();
4834
- if (nextLine === "" || nextLine === "*/" || nextLine === "*") continue;
4835
- if (nextLine.startsWith("*") || nextLine.startsWith("//")) continue;
4836
- const funcMatch = nextLine.match(FUNCTION_DECL_RE);
4837
- if (funcMatch && funcMatch[1]) {
4838
- entries.push({
4839
- file: relativePath,
4840
- function: funcMatch[1],
4841
- source: "annotation"
4842
- });
4843
- } else {
4844
- const constMatch = nextLine.match(CONST_DECL_RE);
4845
- if (constMatch && constMatch[1]) {
4846
- entries.push({
4847
- file: relativePath,
4848
- function: constMatch[1],
4849
- source: "annotation"
4850
- });
4851
- }
4852
- }
4853
- break;
4912
+ if (!lines[i].includes("@perf-critical")) continue;
4913
+ const fnName = this.resolveFunctionName(lines, i + 1);
4914
+ if (fnName) {
4915
+ entries.push({ file: relativePath, function: fnName, source: "annotation" });
4854
4916
  }
4855
4917
  }
4856
4918
  }
@@ -5005,14 +5067,19 @@ function detectFileStatus(part) {
5005
5067
  if (part.includes("rename from")) return "renamed";
5006
5068
  return "modified";
5007
5069
  }
5008
- function parseDiffPart(part) {
5070
+ function parseDiffHeader(part) {
5009
5071
  if (!part.trim()) return null;
5010
5072
  const headerMatch = /diff --git a\/(.+?) b\/(.+?)(?:\n|$)/.exec(part);
5011
5073
  if (!headerMatch || !headerMatch[2]) return null;
5074
+ return headerMatch[2];
5075
+ }
5076
+ function parseDiffPart(part) {
5077
+ const path31 = parseDiffHeader(part);
5078
+ if (!path31) return null;
5012
5079
  const additionRegex = /^\+(?!\+\+)/gm;
5013
5080
  const deletionRegex = /^-(?!--)/gm;
5014
5081
  return {
5015
- path: headerMatch[2],
5082
+ path: path31,
5016
5083
  status: detectFileStatus(part),
5017
5084
  additions: (part.match(additionRegex) || []).length,
5018
5085
  deletions: (part.match(deletionRegex) || []).length
@@ -5034,100 +5101,136 @@ function parseDiff(diff2) {
5034
5101
  });
5035
5102
  }
5036
5103
  }
5037
- async function analyzeDiff(changes, options, graphImpactData) {
5038
- if (!options?.enabled) {
5039
- return (0, import_types.Ok)([]);
5104
+ function checkForbiddenPatterns(diff2, forbiddenPatterns, nextId) {
5105
+ const items = [];
5106
+ if (!forbiddenPatterns) return items;
5107
+ for (const forbidden of forbiddenPatterns) {
5108
+ const pattern = typeof forbidden.pattern === "string" ? new RegExp(forbidden.pattern, "g") : forbidden.pattern;
5109
+ if (!pattern.test(diff2)) continue;
5110
+ items.push({
5111
+ id: nextId(),
5112
+ category: "diff",
5113
+ check: `Forbidden pattern: ${forbidden.pattern}`,
5114
+ passed: false,
5115
+ severity: forbidden.severity,
5116
+ details: forbidden.message,
5117
+ suggestion: `Remove occurrences of ${forbidden.pattern}`
5118
+ });
5040
5119
  }
5120
+ return items;
5121
+ }
5122
+ function checkMaxChangedFiles(files, maxChangedFiles, nextId) {
5123
+ if (!maxChangedFiles || files.length <= maxChangedFiles) return [];
5124
+ return [
5125
+ {
5126
+ id: nextId(),
5127
+ category: "diff",
5128
+ check: `PR size: ${files.length} files changed`,
5129
+ passed: false,
5130
+ severity: "warning",
5131
+ details: `This PR changes ${files.length} files, which exceeds the recommended maximum of ${maxChangedFiles}`,
5132
+ suggestion: "Consider breaking this into smaller PRs"
5133
+ }
5134
+ ];
5135
+ }
5136
+ function checkFileSizes(files, maxFileSize, nextId) {
5041
5137
  const items = [];
5042
- let itemId = 0;
5043
- if (options.forbiddenPatterns) {
5044
- for (const forbidden of options.forbiddenPatterns) {
5045
- const pattern = typeof forbidden.pattern === "string" ? new RegExp(forbidden.pattern, "g") : forbidden.pattern;
5046
- if (pattern.test(changes.diff)) {
5047
- items.push({
5048
- id: `diff-${++itemId}`,
5049
- category: "diff",
5050
- check: `Forbidden pattern: ${forbidden.pattern}`,
5051
- passed: false,
5052
- severity: forbidden.severity,
5053
- details: forbidden.message,
5054
- suggestion: `Remove occurrences of ${forbidden.pattern}`
5055
- });
5056
- }
5138
+ if (!maxFileSize) return items;
5139
+ for (const file of files) {
5140
+ const totalLines = file.additions + file.deletions;
5141
+ if (totalLines <= maxFileSize) continue;
5142
+ items.push({
5143
+ id: nextId(),
5144
+ category: "diff",
5145
+ check: `File size: ${file.path}`,
5146
+ passed: false,
5147
+ severity: "warning",
5148
+ details: `File has ${totalLines} lines changed, exceeding limit of ${maxFileSize}`,
5149
+ file: file.path,
5150
+ suggestion: "Consider splitting this file into smaller modules"
5151
+ });
5152
+ }
5153
+ return items;
5154
+ }
5155
+ function checkTestCoverageGraph(files, graphImpactData) {
5156
+ const items = [];
5157
+ for (const file of files) {
5158
+ if (file.status !== "added" || !file.path.endsWith(".ts") || file.path.includes(".test.")) {
5159
+ continue;
5057
5160
  }
5161
+ const hasGraphTest = graphImpactData.affectedTests.some((t) => t.coversFile === file.path);
5162
+ if (hasGraphTest) continue;
5163
+ items.push({
5164
+ id: `test-coverage-${file.path}`,
5165
+ category: "diff",
5166
+ check: "Test coverage (graph)",
5167
+ passed: false,
5168
+ severity: "warning",
5169
+ details: `New file ${file.path} has no test file linked in the graph`,
5170
+ file: file.path
5171
+ });
5058
5172
  }
5059
- if (options.maxChangedFiles && changes.files.length > options.maxChangedFiles) {
5173
+ return items;
5174
+ }
5175
+ function checkTestCoverageFilename(files, nextId) {
5176
+ const items = [];
5177
+ const addedSourceFiles = files.filter(
5178
+ (f) => f.status === "added" && f.path.endsWith(".ts") && !f.path.includes(".test.")
5179
+ );
5180
+ const testFiles = files.filter((f) => f.path.includes(".test."));
5181
+ for (const sourceFile of addedSourceFiles) {
5182
+ const expectedTestPath = sourceFile.path.replace(".ts", ".test.ts");
5183
+ const hasTest = testFiles.some(
5184
+ (t) => t.path.includes(expectedTestPath) || t.path.includes(sourceFile.path.replace(".ts", ""))
5185
+ );
5186
+ if (hasTest) continue;
5060
5187
  items.push({
5061
- id: `diff-${++itemId}`,
5188
+ id: nextId(),
5062
5189
  category: "diff",
5063
- check: `PR size: ${changes.files.length} files changed`,
5190
+ check: `Test coverage: ${sourceFile.path}`,
5064
5191
  passed: false,
5065
5192
  severity: "warning",
5066
- details: `This PR changes ${changes.files.length} files, which exceeds the recommended maximum of ${options.maxChangedFiles}`,
5067
- suggestion: "Consider breaking this into smaller PRs"
5193
+ details: "New source file added without corresponding test file",
5194
+ file: sourceFile.path,
5195
+ suggestion: `Add tests in ${expectedTestPath}`
5068
5196
  });
5069
5197
  }
5070
- if (options.maxFileSize) {
5071
- for (const file of changes.files) {
5072
- const totalLines = file.additions + file.deletions;
5073
- if (totalLines > options.maxFileSize) {
5074
- items.push({
5075
- id: `diff-${++itemId}`,
5076
- category: "diff",
5077
- check: `File size: ${file.path}`,
5078
- passed: false,
5079
- severity: "warning",
5080
- details: `File has ${totalLines} lines changed, exceeding limit of ${options.maxFileSize}`,
5081
- file: file.path,
5082
- suggestion: "Consider splitting this file into smaller modules"
5083
- });
5084
- }
5198
+ return items;
5199
+ }
5200
+ function checkDocCoverage2(files, graphImpactData) {
5201
+ const items = [];
5202
+ for (const file of files) {
5203
+ if (file.status !== "modified" || !file.path.endsWith(".ts") || file.path.includes(".test.")) {
5204
+ continue;
5085
5205
  }
5206
+ const hasDoc = graphImpactData.affectedDocs.some((d) => d.documentsFile === file.path);
5207
+ if (hasDoc) continue;
5208
+ items.push({
5209
+ id: `doc-coverage-${file.path}`,
5210
+ category: "diff",
5211
+ check: "Documentation coverage (graph)",
5212
+ passed: true,
5213
+ severity: "info",
5214
+ details: `Modified file ${file.path} has no documentation linked in the graph`,
5215
+ file: file.path
5216
+ });
5086
5217
  }
5218
+ return items;
5219
+ }
5220
+ async function analyzeDiff(changes, options, graphImpactData) {
5221
+ if (!options?.enabled) {
5222
+ return (0, import_types.Ok)([]);
5223
+ }
5224
+ let itemId = 0;
5225
+ const nextId = () => `diff-${++itemId}`;
5226
+ const items = [
5227
+ ...checkForbiddenPatterns(changes.diff, options.forbiddenPatterns, nextId),
5228
+ ...checkMaxChangedFiles(changes.files, options.maxChangedFiles, nextId),
5229
+ ...checkFileSizes(changes.files, options.maxFileSize, nextId)
5230
+ ];
5087
5231
  if (options.checkTestCoverage) {
5088
- if (graphImpactData) {
5089
- for (const file of changes.files) {
5090
- if (file.status === "added" && file.path.endsWith(".ts") && !file.path.includes(".test.")) {
5091
- const hasGraphTest = graphImpactData.affectedTests.some(
5092
- (t) => t.coversFile === file.path
5093
- );
5094
- if (!hasGraphTest) {
5095
- items.push({
5096
- id: `test-coverage-${file.path}`,
5097
- category: "diff",
5098
- check: "Test coverage (graph)",
5099
- passed: false,
5100
- severity: "warning",
5101
- details: `New file ${file.path} has no test file linked in the graph`,
5102
- file: file.path
5103
- });
5104
- }
5105
- }
5106
- }
5107
- } else {
5108
- const addedSourceFiles = changes.files.filter(
5109
- (f) => f.status === "added" && f.path.endsWith(".ts") && !f.path.includes(".test.")
5110
- );
5111
- const testFiles = changes.files.filter((f) => f.path.includes(".test."));
5112
- for (const sourceFile of addedSourceFiles) {
5113
- const expectedTestPath = sourceFile.path.replace(".ts", ".test.ts");
5114
- const hasTest = testFiles.some(
5115
- (t) => t.path.includes(expectedTestPath) || t.path.includes(sourceFile.path.replace(".ts", ""))
5116
- );
5117
- if (!hasTest) {
5118
- items.push({
5119
- id: `diff-${++itemId}`,
5120
- category: "diff",
5121
- check: `Test coverage: ${sourceFile.path}`,
5122
- passed: false,
5123
- severity: "warning",
5124
- details: "New source file added without corresponding test file",
5125
- file: sourceFile.path,
5126
- suggestion: `Add tests in ${expectedTestPath}`
5127
- });
5128
- }
5129
- }
5130
- }
5232
+ const coverageItems = graphImpactData ? checkTestCoverageGraph(changes.files, graphImpactData) : checkTestCoverageFilename(changes.files, nextId);
5233
+ items.push(...coverageItems);
5131
5234
  }
5132
5235
  if (graphImpactData && graphImpactData.impactScope > 20) {
5133
5236
  items.push({
@@ -5140,22 +5243,7 @@ async function analyzeDiff(changes, options, graphImpactData) {
5140
5243
  });
5141
5244
  }
5142
5245
  if (graphImpactData) {
5143
- for (const file of changes.files) {
5144
- if (file.status === "modified" && file.path.endsWith(".ts") && !file.path.includes(".test.")) {
5145
- const hasDoc = graphImpactData.affectedDocs.some((d) => d.documentsFile === file.path);
5146
- if (!hasDoc) {
5147
- items.push({
5148
- id: `doc-coverage-${file.path}`,
5149
- category: "diff",
5150
- check: "Documentation coverage (graph)",
5151
- passed: true,
5152
- severity: "info",
5153
- details: `Modified file ${file.path} has no documentation linked in the graph`,
5154
- file: file.path
5155
- });
5156
- }
5157
- }
5158
- }
5246
+ items.push(...checkDocCoverage2(changes.files, graphImpactData));
5159
5247
  }
5160
5248
  return (0, import_types.Ok)(items);
5161
5249
  }
@@ -5739,6 +5827,28 @@ function constraintRuleId(category, scope, description) {
5739
5827
  }
5740
5828
 
5741
5829
  // src/architecture/collectors/circular-deps.ts
5830
+ function makeStubParser() {
5831
+ return {
5832
+ name: "typescript",
5833
+ extensions: [".ts", ".tsx"],
5834
+ parseFile: async () => ({ ok: false, error: { code: "PARSE_ERROR", message: "not needed" } }),
5835
+ extractImports: () => ({ ok: false, error: { code: "EXTRACT_ERROR", message: "not needed" } }),
5836
+ extractExports: () => ({ ok: false, error: { code: "EXTRACT_ERROR", message: "not needed" } }),
5837
+ health: async () => ({ ok: true, value: { available: true } })
5838
+ };
5839
+ }
5840
+ function mapCycleViolations(cycles, rootDir, category) {
5841
+ return cycles.map((cycle) => {
5842
+ const cyclePath = cycle.cycle.map((f) => relativePosix(rootDir, f)).join(" -> ");
5843
+ const firstFile = relativePosix(rootDir, cycle.cycle[0]);
5844
+ return {
5845
+ id: violationId(firstFile, category, cyclePath),
5846
+ file: firstFile,
5847
+ detail: `Circular dependency: ${cyclePath}`,
5848
+ severity: cycle.severity
5849
+ };
5850
+ });
5851
+ }
5742
5852
  var CircularDepsCollector = class {
5743
5853
  category = "circular-deps";
5744
5854
  getRules(_config, _rootDir) {
@@ -5754,21 +5864,7 @@ var CircularDepsCollector = class {
5754
5864
  }
5755
5865
  async collect(_config, rootDir) {
5756
5866
  const files = await findFiles("**/*.ts", rootDir);
5757
- const stubParser = {
5758
- name: "typescript",
5759
- extensions: [".ts", ".tsx"],
5760
- parseFile: async () => ({ ok: false, error: { code: "PARSE_ERROR", message: "not needed" } }),
5761
- extractImports: () => ({
5762
- ok: false,
5763
- error: { code: "EXTRACT_ERROR", message: "not needed" }
5764
- }),
5765
- extractExports: () => ({
5766
- ok: false,
5767
- error: { code: "EXTRACT_ERROR", message: "not needed" }
5768
- }),
5769
- health: async () => ({ ok: true, value: { available: true } })
5770
- };
5771
- const graphResult = await buildDependencyGraph(files, stubParser);
5867
+ const graphResult = await buildDependencyGraph(files, makeStubParser());
5772
5868
  if (!graphResult.ok) {
5773
5869
  return [
5774
5870
  {
@@ -5793,16 +5889,7 @@ var CircularDepsCollector = class {
5793
5889
  ];
5794
5890
  }
5795
5891
  const { cycles, largestCycle } = result.value;
5796
- const violations = cycles.map((cycle) => {
5797
- const cyclePath = cycle.cycle.map((f) => relativePosix(rootDir, f)).join(" -> ");
5798
- const firstFile = relativePosix(rootDir, cycle.cycle[0]);
5799
- return {
5800
- id: violationId(firstFile, this.category, cyclePath),
5801
- file: firstFile,
5802
- detail: `Circular dependency: ${cyclePath}`,
5803
- severity: cycle.severity
5804
- };
5805
- });
5892
+ const violations = mapCycleViolations(cycles, rootDir, this.category);
5806
5893
  return [
5807
5894
  {
5808
5895
  category: this.category,
@@ -5816,6 +5903,30 @@ var CircularDepsCollector = class {
5816
5903
  };
5817
5904
 
5818
5905
  // src/architecture/collectors/layer-violations.ts
5906
+ function makeLayerStubParser() {
5907
+ return {
5908
+ name: "typescript",
5909
+ extensions: [".ts", ".tsx"],
5910
+ parseFile: async () => ({ ok: false, error: { code: "PARSE_ERROR", message: "" } }),
5911
+ extractImports: () => ({ ok: false, error: { code: "EXTRACT_ERROR", message: "" } }),
5912
+ extractExports: () => ({ ok: false, error: { code: "EXTRACT_ERROR", message: "" } }),
5913
+ health: async () => ({ ok: true, value: { available: true } })
5914
+ };
5915
+ }
5916
+ function mapLayerViolations(layerViolations, rootDir, category) {
5917
+ return layerViolations.map((v) => {
5918
+ const relFile = relativePosix(rootDir, v.file);
5919
+ const relImport = relativePosix(rootDir, v.imports);
5920
+ const detail = `${v.fromLayer} -> ${v.toLayer}: ${relFile} imports ${relImport}`;
5921
+ return {
5922
+ id: violationId(relFile, category ?? "", detail),
5923
+ file: relFile,
5924
+ category,
5925
+ detail,
5926
+ severity: "error"
5927
+ };
5928
+ });
5929
+ }
5819
5930
  var LayerViolationCollector = class {
5820
5931
  category = "layer-violations";
5821
5932
  getRules(_config, _rootDir) {
@@ -5830,18 +5941,10 @@ var LayerViolationCollector = class {
5830
5941
  ];
5831
5942
  }
5832
5943
  async collect(_config, rootDir) {
5833
- const stubParser = {
5834
- name: "typescript",
5835
- extensions: [".ts", ".tsx"],
5836
- parseFile: async () => ({ ok: false, error: { code: "PARSE_ERROR", message: "" } }),
5837
- extractImports: () => ({ ok: false, error: { code: "EXTRACT_ERROR", message: "" } }),
5838
- extractExports: () => ({ ok: false, error: { code: "EXTRACT_ERROR", message: "" } }),
5839
- health: async () => ({ ok: true, value: { available: true } })
5840
- };
5841
5944
  const result = await validateDependencies({
5842
5945
  layers: [],
5843
5946
  rootDir,
5844
- parser: stubParser,
5947
+ parser: makeLayerStubParser(),
5845
5948
  fallbackBehavior: "skip"
5846
5949
  });
5847
5950
  if (!result.ok) {
@@ -5855,33 +5958,52 @@ var LayerViolationCollector = class {
5855
5958
  }
5856
5959
  ];
5857
5960
  }
5858
- const layerViolations = result.value.violations.filter(
5859
- (v) => v.reason === "WRONG_LAYER"
5961
+ const violations = mapLayerViolations(
5962
+ result.value.violations.filter((v) => v.reason === "WRONG_LAYER"),
5963
+ rootDir,
5964
+ this.category
5860
5965
  );
5861
- const violations = layerViolations.map((v) => {
5862
- const relFile = relativePosix(rootDir, v.file);
5863
- const relImport = relativePosix(rootDir, v.imports);
5864
- const detail = `${v.fromLayer} -> ${v.toLayer}: ${relFile} imports ${relImport}`;
5865
- return {
5866
- id: violationId(relFile, this.category, detail),
5867
- file: relFile,
5868
- category: this.category,
5869
- detail,
5870
- severity: "error"
5871
- };
5872
- });
5873
- return [
5874
- {
5875
- category: this.category,
5876
- scope: "project",
5877
- value: violations.length,
5878
- violations
5879
- }
5880
- ];
5966
+ return [{ category: this.category, scope: "project", value: violations.length, violations }];
5881
5967
  }
5882
5968
  };
5883
5969
 
5884
5970
  // src/architecture/collectors/complexity.ts
5971
+ function buildSnapshot2(files, rootDir) {
5972
+ return {
5973
+ files: files.map((f) => ({
5974
+ path: f,
5975
+ ast: { type: "Program", body: null, language: "typescript" },
5976
+ imports: [],
5977
+ exports: [],
5978
+ internalSymbols: [],
5979
+ jsDocComments: []
5980
+ })),
5981
+ dependencyGraph: { nodes: [], edges: [] },
5982
+ exportMap: { byFile: /* @__PURE__ */ new Map(), byName: /* @__PURE__ */ new Map() },
5983
+ docs: [],
5984
+ codeReferences: [],
5985
+ entryPoints: [],
5986
+ rootDir,
5987
+ config: { rootDir, analyze: {} },
5988
+ buildTime: 0
5989
+ };
5990
+ }
5991
+ function resolveMaxComplexity(config) {
5992
+ const threshold = config.thresholds.complexity;
5993
+ return typeof threshold === "number" ? threshold : threshold?.max ?? 15;
5994
+ }
5995
+ function mapComplexityViolations(complexityViolations, rootDir, category) {
5996
+ return complexityViolations.filter((v) => v.severity === "error" || v.severity === "warning").map((v) => {
5997
+ const relFile = relativePosix(rootDir, v.file);
5998
+ return {
5999
+ id: violationId(relFile, category ?? "", `${v.metric}:${v.function}`),
6000
+ file: relFile,
6001
+ category,
6002
+ detail: `${v.metric}=${v.value} in ${v.function} (threshold: ${v.threshold})`,
6003
+ severity: v.severity
6004
+ };
6005
+ });
6006
+ }
5885
6007
  var ComplexityCollector = class {
5886
6008
  category = "complexity";
5887
6009
  getRules(_config, _rootDir) {
@@ -5897,32 +6019,11 @@ var ComplexityCollector = class {
5897
6019
  }
5898
6020
  async collect(_config, rootDir) {
5899
6021
  const files = await findFiles("**/*.ts", rootDir);
5900
- const snapshot = {
5901
- files: files.map((f) => ({
5902
- path: f,
5903
- ast: { type: "Program", body: null, language: "typescript" },
5904
- imports: [],
5905
- exports: [],
5906
- internalSymbols: [],
5907
- jsDocComments: []
5908
- })),
5909
- dependencyGraph: { nodes: [], edges: [] },
5910
- exportMap: { byFile: /* @__PURE__ */ new Map(), byName: /* @__PURE__ */ new Map() },
5911
- docs: [],
5912
- codeReferences: [],
5913
- entryPoints: [],
5914
- rootDir,
5915
- config: { rootDir, analyze: {} },
5916
- buildTime: 0
5917
- };
5918
- const complexityThreshold = _config.thresholds.complexity;
5919
- const maxComplexity = typeof complexityThreshold === "number" ? complexityThreshold : complexityThreshold?.max ?? 15;
6022
+ const snapshot = buildSnapshot2(files, rootDir);
6023
+ const maxComplexity = resolveMaxComplexity(_config);
5920
6024
  const complexityConfig = {
5921
6025
  thresholds: {
5922
- cyclomaticComplexity: {
5923
- error: maxComplexity,
5924
- warn: Math.floor(maxComplexity * 0.7)
5925
- }
6026
+ cyclomaticComplexity: { error: maxComplexity, warn: Math.floor(maxComplexity * 0.7) }
5926
6027
  }
5927
6028
  };
5928
6029
  const result = await detectComplexityViolations(snapshot, complexityConfig);
@@ -5938,20 +6039,7 @@ var ComplexityCollector = class {
5938
6039
  ];
5939
6040
  }
5940
6041
  const { violations: complexityViolations, stats } = result.value;
5941
- const filtered = complexityViolations.filter(
5942
- (v) => v.severity === "error" || v.severity === "warning"
5943
- );
5944
- const violations = filtered.map((v) => {
5945
- const relFile = relativePosix(rootDir, v.file);
5946
- const idDetail = `${v.metric}:${v.function}`;
5947
- return {
5948
- id: violationId(relFile, this.category, idDetail),
5949
- file: relFile,
5950
- category: this.category,
5951
- detail: `${v.metric}=${v.value} in ${v.function} (threshold: ${v.threshold})`,
5952
- severity: v.severity
5953
- };
5954
- });
6042
+ const violations = mapComplexityViolations(complexityViolations, rootDir, this.category);
5955
6043
  return [
5956
6044
  {
5957
6045
  category: this.category,
@@ -5968,6 +6056,38 @@ var ComplexityCollector = class {
5968
6056
  };
5969
6057
 
5970
6058
  // src/architecture/collectors/coupling.ts
6059
+ function buildCouplingSnapshot(files, rootDir) {
6060
+ return {
6061
+ files: files.map((f) => ({
6062
+ path: f,
6063
+ ast: { type: "Program", body: null, language: "typescript" },
6064
+ imports: [],
6065
+ exports: [],
6066
+ internalSymbols: [],
6067
+ jsDocComments: []
6068
+ })),
6069
+ dependencyGraph: { nodes: [], edges: [] },
6070
+ exportMap: { byFile: /* @__PURE__ */ new Map(), byName: /* @__PURE__ */ new Map() },
6071
+ docs: [],
6072
+ codeReferences: [],
6073
+ entryPoints: [],
6074
+ rootDir,
6075
+ config: { rootDir, analyze: {} },
6076
+ buildTime: 0
6077
+ };
6078
+ }
6079
+ function mapCouplingViolations(couplingViolations, rootDir, category) {
6080
+ return couplingViolations.filter((v) => v.severity === "error" || v.severity === "warning").map((v) => {
6081
+ const relFile = relativePosix(rootDir, v.file);
6082
+ return {
6083
+ id: violationId(relFile, category ?? "", v.metric),
6084
+ file: relFile,
6085
+ category,
6086
+ detail: `${v.metric}=${v.value} (threshold: ${v.threshold})`,
6087
+ severity: v.severity
6088
+ };
6089
+ });
6090
+ }
5971
6091
  var CouplingCollector = class {
5972
6092
  category = "coupling";
5973
6093
  getRules(_config, _rootDir) {
@@ -5983,24 +6103,7 @@ var CouplingCollector = class {
5983
6103
  }
5984
6104
  async collect(_config, rootDir) {
5985
6105
  const files = await findFiles("**/*.ts", rootDir);
5986
- const snapshot = {
5987
- files: files.map((f) => ({
5988
- path: f,
5989
- ast: { type: "Program", body: null, language: "typescript" },
5990
- imports: [],
5991
- exports: [],
5992
- internalSymbols: [],
5993
- jsDocComments: []
5994
- })),
5995
- dependencyGraph: { nodes: [], edges: [] },
5996
- exportMap: { byFile: /* @__PURE__ */ new Map(), byName: /* @__PURE__ */ new Map() },
5997
- docs: [],
5998
- codeReferences: [],
5999
- entryPoints: [],
6000
- rootDir,
6001
- config: { rootDir, analyze: {} },
6002
- buildTime: 0
6003
- };
6106
+ const snapshot = buildCouplingSnapshot(files, rootDir);
6004
6107
  const result = await detectCouplingViolations(snapshot);
6005
6108
  if (!result.ok) {
6006
6109
  return [
@@ -6014,20 +6117,7 @@ var CouplingCollector = class {
6014
6117
  ];
6015
6118
  }
6016
6119
  const { violations: couplingViolations, stats } = result.value;
6017
- const filtered = couplingViolations.filter(
6018
- (v) => v.severity === "error" || v.severity === "warning"
6019
- );
6020
- const violations = filtered.map((v) => {
6021
- const relFile = relativePosix(rootDir, v.file);
6022
- const idDetail = `${v.metric}`;
6023
- return {
6024
- id: violationId(relFile, this.category, idDetail),
6025
- file: relFile,
6026
- category: this.category,
6027
- detail: `${v.metric}=${v.value} (threshold: ${v.threshold})`,
6028
- severity: v.severity
6029
- };
6030
- });
6120
+ const violations = mapCouplingViolations(couplingViolations, rootDir, this.category);
6031
6121
  return [
6032
6122
  {
6033
6123
  category: this.category,
@@ -6041,6 +6131,30 @@ var CouplingCollector = class {
6041
6131
  };
6042
6132
 
6043
6133
  // src/architecture/collectors/forbidden-imports.ts
6134
+ function makeForbiddenStubParser() {
6135
+ return {
6136
+ name: "typescript",
6137
+ extensions: [".ts", ".tsx"],
6138
+ parseFile: async () => ({ ok: false, error: { code: "PARSE_ERROR", message: "" } }),
6139
+ extractImports: () => ({ ok: false, error: { code: "EXTRACT_ERROR", message: "" } }),
6140
+ extractExports: () => ({ ok: false, error: { code: "EXTRACT_ERROR", message: "" } }),
6141
+ health: async () => ({ ok: true, value: { available: true } })
6142
+ };
6143
+ }
6144
+ function mapForbiddenImportViolations(forbidden, rootDir, category) {
6145
+ return forbidden.map((v) => {
6146
+ const relFile = relativePosix(rootDir, v.file);
6147
+ const relImport = relativePosix(rootDir, v.imports);
6148
+ const detail = `forbidden import: ${relFile} -> ${relImport}`;
6149
+ return {
6150
+ id: violationId(relFile, category ?? "", detail),
6151
+ file: relFile,
6152
+ category,
6153
+ detail,
6154
+ severity: "error"
6155
+ };
6156
+ });
6157
+ }
6044
6158
  var ForbiddenImportCollector = class {
6045
6159
  category = "forbidden-imports";
6046
6160
  getRules(_config, _rootDir) {
@@ -6055,18 +6169,10 @@ var ForbiddenImportCollector = class {
6055
6169
  ];
6056
6170
  }
6057
6171
  async collect(_config, rootDir) {
6058
- const stubParser = {
6059
- name: "typescript",
6060
- extensions: [".ts", ".tsx"],
6061
- parseFile: async () => ({ ok: false, error: { code: "PARSE_ERROR", message: "" } }),
6062
- extractImports: () => ({ ok: false, error: { code: "EXTRACT_ERROR", message: "" } }),
6063
- extractExports: () => ({ ok: false, error: { code: "EXTRACT_ERROR", message: "" } }),
6064
- health: async () => ({ ok: true, value: { available: true } })
6065
- };
6066
6172
  const result = await validateDependencies({
6067
6173
  layers: [],
6068
6174
  rootDir,
6069
- parser: stubParser,
6175
+ parser: makeForbiddenStubParser(),
6070
6176
  fallbackBehavior: "skip"
6071
6177
  });
6072
6178
  if (!result.ok) {
@@ -6080,91 +6186,94 @@ var ForbiddenImportCollector = class {
6080
6186
  }
6081
6187
  ];
6082
6188
  }
6083
- const forbidden = result.value.violations.filter(
6084
- (v) => v.reason === "FORBIDDEN_IMPORT"
6189
+ const violations = mapForbiddenImportViolations(
6190
+ result.value.violations.filter((v) => v.reason === "FORBIDDEN_IMPORT"),
6191
+ rootDir,
6192
+ this.category
6085
6193
  );
6086
- const violations = forbidden.map((v) => {
6087
- const relFile = relativePosix(rootDir, v.file);
6088
- const relImport = relativePosix(rootDir, v.imports);
6089
- const detail = `forbidden import: ${relFile} -> ${relImport}`;
6090
- return {
6091
- id: violationId(relFile, this.category, detail),
6092
- file: relFile,
6093
- category: this.category,
6094
- detail,
6095
- severity: "error"
6096
- };
6097
- });
6098
- return [
6099
- {
6100
- category: this.category,
6101
- scope: "project",
6102
- value: violations.length,
6103
- violations
6104
- }
6105
- ];
6194
+ return [{ category: this.category, scope: "project", value: violations.length, violations }];
6106
6195
  }
6107
6196
  };
6108
6197
 
6109
6198
  // src/architecture/collectors/module-size.ts
6110
6199
  var import_promises2 = require("fs/promises");
6111
6200
  var import_node_path4 = require("path");
6112
- async function discoverModules(rootDir) {
6113
- const modules = [];
6114
- async function scanDir(dir) {
6115
- let entries;
6116
- try {
6117
- entries = await (0, import_promises2.readdir)(dir, { withFileTypes: true });
6118
- } catch {
6119
- return;
6120
- }
6121
- const tsFiles = [];
6122
- const subdirs = [];
6123
- for (const entry of entries) {
6124
- if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.name === "dist") {
6125
- continue;
6126
- }
6127
- const fullPath = (0, import_node_path4.join)(dir, entry.name);
6128
- if (entry.isDirectory()) {
6129
- subdirs.push(fullPath);
6130
- } else if (entry.isFile() && (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) && !entry.name.endsWith(".test.ts") && !entry.name.endsWith(".test.tsx") && !entry.name.endsWith(".spec.ts")) {
6131
- tsFiles.push(fullPath);
6132
- }
6133
- }
6134
- if (tsFiles.length > 0) {
6135
- let totalLoc = 0;
6136
- for (const f of tsFiles) {
6137
- try {
6138
- const content = await (0, import_promises2.readFile)(f, "utf-8");
6139
- totalLoc += content.split("\n").filter((line) => line.trim().length > 0).length;
6140
- } catch {
6141
- }
6142
- }
6143
- modules.push({
6144
- modulePath: relativePosix(rootDir, dir),
6145
- fileCount: tsFiles.length,
6146
- totalLoc,
6147
- files: tsFiles.map((f) => relativePosix(rootDir, f))
6148
- });
6201
+ function isSkippedEntry(name) {
6202
+ return name.startsWith(".") || name === "node_modules" || name === "dist";
6203
+ }
6204
+ function isTsSourceFile(name) {
6205
+ if (!name.endsWith(".ts") && !name.endsWith(".tsx")) return false;
6206
+ if (name.endsWith(".test.ts") || name.endsWith(".test.tsx") || name.endsWith(".spec.ts"))
6207
+ return false;
6208
+ return true;
6209
+ }
6210
+ async function countLoc(filePath) {
6211
+ try {
6212
+ const content = await (0, import_promises2.readFile)(filePath, "utf-8");
6213
+ return content.split("\n").filter((line) => line.trim().length > 0).length;
6214
+ } catch {
6215
+ return 0;
6216
+ }
6217
+ }
6218
+ async function buildModuleStats(rootDir, dir, tsFiles) {
6219
+ let totalLoc = 0;
6220
+ for (const f of tsFiles) {
6221
+ totalLoc += await countLoc(f);
6222
+ }
6223
+ return {
6224
+ modulePath: relativePosix(rootDir, dir),
6225
+ fileCount: tsFiles.length,
6226
+ totalLoc,
6227
+ files: tsFiles.map((f) => relativePosix(rootDir, f))
6228
+ };
6229
+ }
6230
+ async function scanDir(rootDir, dir, modules) {
6231
+ let entries;
6232
+ try {
6233
+ entries = await (0, import_promises2.readdir)(dir, { withFileTypes: true });
6234
+ } catch {
6235
+ return;
6236
+ }
6237
+ const tsFiles = [];
6238
+ const subdirs = [];
6239
+ for (const entry of entries) {
6240
+ if (isSkippedEntry(entry.name)) continue;
6241
+ const fullPath = (0, import_node_path4.join)(dir, entry.name);
6242
+ if (entry.isDirectory()) {
6243
+ subdirs.push(fullPath);
6244
+ continue;
6149
6245
  }
6150
- for (const sub of subdirs) {
6151
- await scanDir(sub);
6246
+ if (entry.isFile() && isTsSourceFile(entry.name)) {
6247
+ tsFiles.push(fullPath);
6152
6248
  }
6153
6249
  }
6154
- await scanDir(rootDir);
6250
+ if (tsFiles.length > 0) {
6251
+ modules.push(await buildModuleStats(rootDir, dir, tsFiles));
6252
+ }
6253
+ for (const sub of subdirs) {
6254
+ await scanDir(rootDir, sub, modules);
6255
+ }
6256
+ }
6257
+ async function discoverModules(rootDir) {
6258
+ const modules = [];
6259
+ await scanDir(rootDir, rootDir, modules);
6155
6260
  return modules;
6156
6261
  }
6262
+ function extractThresholds(config) {
6263
+ const thresholds = config.thresholds["module-size"];
6264
+ let maxLoc = Infinity;
6265
+ let maxFiles = Infinity;
6266
+ if (typeof thresholds === "object" && thresholds !== null) {
6267
+ const t = thresholds;
6268
+ if (t.maxLoc !== void 0) maxLoc = t.maxLoc;
6269
+ if (t.maxFiles !== void 0) maxFiles = t.maxFiles;
6270
+ }
6271
+ return { maxLoc, maxFiles };
6272
+ }
6157
6273
  var ModuleSizeCollector = class {
6158
6274
  category = "module-size";
6159
6275
  getRules(config, _rootDir) {
6160
- const thresholds = config.thresholds["module-size"];
6161
- let maxLoc = Infinity;
6162
- let maxFiles = Infinity;
6163
- if (typeof thresholds === "object" && thresholds !== null) {
6164
- const t = thresholds;
6165
- if (t.maxLoc !== void 0) maxLoc = t.maxLoc;
6166
- if (t.maxFiles !== void 0) maxFiles = t.maxFiles;
6167
- }
6276
+ const { maxLoc, maxFiles } = extractThresholds(config);
6168
6277
  const rules = [];
6169
6278
  if (maxLoc < Infinity) {
6170
6279
  const desc = `Module LOC must not exceed ${maxLoc}`;
@@ -6197,14 +6306,7 @@ var ModuleSizeCollector = class {
6197
6306
  }
6198
6307
  async collect(config, rootDir) {
6199
6308
  const modules = await discoverModules(rootDir);
6200
- const thresholds = config.thresholds["module-size"];
6201
- let maxLoc = Infinity;
6202
- let maxFiles = Infinity;
6203
- if (typeof thresholds === "object" && thresholds !== null) {
6204
- const t = thresholds;
6205
- if (t.maxLoc !== void 0) maxLoc = t.maxLoc;
6206
- if (t.maxFiles !== void 0) maxFiles = t.maxFiles;
6207
- }
6309
+ const { maxLoc, maxFiles } = extractThresholds(config);
6208
6310
  return modules.map((mod) => {
6209
6311
  const violations = [];
6210
6312
  if (mod.totalLoc > maxLoc) {
@@ -6254,27 +6356,33 @@ function extractImportSources(content, filePath) {
6254
6356
  }
6255
6357
  return sources;
6256
6358
  }
6257
- async function collectTsFiles(dir) {
6258
- const results = [];
6259
- async function scan(d) {
6260
- let entries;
6261
- try {
6262
- entries = await (0, import_promises3.readdir)(d, { withFileTypes: true });
6263
- } catch {
6264
- return;
6265
- }
6266
- for (const entry of entries) {
6267
- if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.name === "dist")
6268
- continue;
6269
- const fullPath = (0, import_node_path5.join)(d, entry.name);
6270
- if (entry.isDirectory()) {
6271
- await scan(fullPath);
6272
- } else if (entry.isFile() && (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) && !entry.name.endsWith(".test.ts") && !entry.name.endsWith(".test.tsx") && !entry.name.endsWith(".spec.ts")) {
6273
- results.push(fullPath);
6274
- }
6359
+ function isSkippedEntry2(name) {
6360
+ return name.startsWith(".") || name === "node_modules" || name === "dist";
6361
+ }
6362
+ function isTsSourceFile2(name) {
6363
+ if (!name.endsWith(".ts") && !name.endsWith(".tsx")) return false;
6364
+ return !name.endsWith(".test.ts") && !name.endsWith(".test.tsx") && !name.endsWith(".spec.ts");
6365
+ }
6366
+ async function scanDir2(d, results) {
6367
+ let entries;
6368
+ try {
6369
+ entries = await (0, import_promises3.readdir)(d, { withFileTypes: true });
6370
+ } catch {
6371
+ return;
6372
+ }
6373
+ for (const entry of entries) {
6374
+ if (isSkippedEntry2(entry.name)) continue;
6375
+ const fullPath = (0, import_node_path5.join)(d, entry.name);
6376
+ if (entry.isDirectory()) {
6377
+ await scanDir2(fullPath, results);
6378
+ } else if (entry.isFile() && isTsSourceFile2(entry.name)) {
6379
+ results.push(fullPath);
6275
6380
  }
6276
6381
  }
6277
- await scan(dir);
6382
+ }
6383
+ async function collectTsFiles(dir) {
6384
+ const results = [];
6385
+ await scanDir2(dir, results);
6278
6386
  return results;
6279
6387
  }
6280
6388
  function computeLongestChain(file, graph, visited, memo) {
@@ -6305,34 +6413,42 @@ var DepDepthCollector = class {
6305
6413
  }
6306
6414
  ];
6307
6415
  }
6308
- async collect(config, rootDir) {
6309
- const allFiles = await collectTsFiles(rootDir);
6416
+ async buildImportGraph(allFiles) {
6310
6417
  const graph = /* @__PURE__ */ new Map();
6311
6418
  const fileSet = new Set(allFiles);
6312
6419
  for (const file of allFiles) {
6313
6420
  try {
6314
6421
  const content = await (0, import_promises3.readFile)(file, "utf-8");
6315
- const imports = extractImportSources(content, file).filter((imp) => fileSet.has(imp));
6316
- graph.set(file, imports);
6422
+ graph.set(
6423
+ file,
6424
+ extractImportSources(content, file).filter((imp) => fileSet.has(imp))
6425
+ );
6317
6426
  } catch {
6318
6427
  graph.set(file, []);
6319
6428
  }
6320
6429
  }
6430
+ return graph;
6431
+ }
6432
+ buildModuleMap(allFiles, rootDir) {
6321
6433
  const moduleMap = /* @__PURE__ */ new Map();
6322
6434
  for (const file of allFiles) {
6323
6435
  const relDir = relativePosix(rootDir, (0, import_node_path5.dirname)(file));
6324
6436
  if (!moduleMap.has(relDir)) moduleMap.set(relDir, []);
6325
6437
  moduleMap.get(relDir).push(file);
6326
6438
  }
6439
+ return moduleMap;
6440
+ }
6441
+ async collect(config, rootDir) {
6442
+ const allFiles = await collectTsFiles(rootDir);
6443
+ const graph = await this.buildImportGraph(allFiles);
6444
+ const moduleMap = this.buildModuleMap(allFiles, rootDir);
6327
6445
  const memo = /* @__PURE__ */ new Map();
6328
6446
  const threshold = typeof config.thresholds["dependency-depth"] === "number" ? config.thresholds["dependency-depth"] : Infinity;
6329
6447
  const results = [];
6330
6448
  for (const [modulePath, files] of moduleMap) {
6331
- let longestChain = 0;
6332
- for (const file of files) {
6333
- const depth = computeLongestChain(file, graph, /* @__PURE__ */ new Set(), memo);
6334
- if (depth > longestChain) longestChain = depth;
6335
- }
6449
+ const longestChain = files.reduce((max, file) => {
6450
+ return Math.max(max, computeLongestChain(file, graph, /* @__PURE__ */ new Set(), memo));
6451
+ }, 0);
6336
6452
  const violations = [];
6337
6453
  if (longestChain > threshold) {
6338
6454
  violations.push({
@@ -6429,10 +6545,26 @@ function hasMatchingViolation(rule, violationsByCategory) {
6429
6545
  }
6430
6546
 
6431
6547
  // src/architecture/detect-stale.ts
6548
+ function evaluateStaleNode(node, now, cutoff) {
6549
+ const lastViolatedAt = node.lastViolatedAt ?? null;
6550
+ const createdAt = node.createdAt;
6551
+ const comparisonTimestamp = lastViolatedAt ?? createdAt;
6552
+ if (!comparisonTimestamp) return null;
6553
+ const timestampMs = new Date(comparisonTimestamp).getTime();
6554
+ if (timestampMs >= cutoff) return null;
6555
+ const daysSince = Math.floor((now - timestampMs) / (24 * 60 * 60 * 1e3));
6556
+ return {
6557
+ id: node.id,
6558
+ category: node.category,
6559
+ description: node.name ?? "",
6560
+ scope: node.scope ?? "project",
6561
+ lastViolatedAt,
6562
+ daysSinceLastViolation: daysSince
6563
+ };
6564
+ }
6432
6565
  function detectStaleConstraints(store, windowDays = 30, category) {
6433
6566
  const now = Date.now();
6434
- const windowMs = windowDays * 24 * 60 * 60 * 1e3;
6435
- const cutoff = now - windowMs;
6567
+ const cutoff = now - windowDays * 24 * 60 * 60 * 1e3;
6436
6568
  let constraints = store.findNodes({ type: "constraint" });
6437
6569
  if (category) {
6438
6570
  constraints = constraints.filter((n) => n.category === category);
@@ -6440,22 +6572,8 @@ function detectStaleConstraints(store, windowDays = 30, category) {
6440
6572
  const totalConstraints = constraints.length;
6441
6573
  const staleConstraints = [];
6442
6574
  for (const node of constraints) {
6443
- const lastViolatedAt = node.lastViolatedAt ?? null;
6444
- const createdAt = node.createdAt;
6445
- const comparisonTimestamp = lastViolatedAt ?? createdAt;
6446
- if (!comparisonTimestamp) continue;
6447
- const timestampMs = new Date(comparisonTimestamp).getTime();
6448
- if (timestampMs < cutoff) {
6449
- const daysSince = Math.floor((now - timestampMs) / (24 * 60 * 60 * 1e3));
6450
- staleConstraints.push({
6451
- id: node.id,
6452
- category: node.category,
6453
- description: node.name ?? "",
6454
- scope: node.scope ?? "project",
6455
- lastViolatedAt,
6456
- daysSinceLastViolation: daysSince
6457
- });
6458
- }
6575
+ const entry = evaluateStaleNode(node, now, cutoff);
6576
+ if (entry) staleConstraints.push(entry);
6459
6577
  }
6460
6578
  staleConstraints.sort((a, b) => b.daysSinceLastViolation - a.daysSinceLastViolation);
6461
6579
  return { staleConstraints, totalConstraints, windowDays };
@@ -6581,44 +6699,57 @@ function collectOrphanedBaselineViolations(baseline, visitedCategories) {
6581
6699
  }
6582
6700
  return resolved;
6583
6701
  }
6702
+ function diffCategory(category, agg, baselineCategory, acc) {
6703
+ const baselineViolationIds = new Set(baselineCategory?.violationIds ?? []);
6704
+ const baselineValue = baselineCategory?.value ?? 0;
6705
+ const classified = classifyViolations(agg.violations, baselineViolationIds);
6706
+ acc.newViolations.push(...classified.newViolations);
6707
+ acc.preExisting.push(...classified.preExisting);
6708
+ const currentViolationIds = new Set(agg.violations.map((v) => v.id));
6709
+ acc.resolvedViolations.push(...findResolvedViolations(baselineCategory, currentViolationIds));
6710
+ if (baselineCategory && agg.value > baselineValue) {
6711
+ acc.regressions.push({
6712
+ category,
6713
+ baselineValue,
6714
+ currentValue: agg.value,
6715
+ delta: agg.value - baselineValue
6716
+ });
6717
+ }
6718
+ }
6584
6719
  function diff(current, baseline) {
6585
6720
  const aggregated = aggregateByCategory(current);
6586
- const newViolations = [];
6587
- const resolvedViolations = [];
6588
- const preExisting = [];
6589
- const regressions = [];
6721
+ const acc = {
6722
+ newViolations: [],
6723
+ resolvedViolations: [],
6724
+ preExisting: [],
6725
+ regressions: []
6726
+ };
6590
6727
  const visitedCategories = /* @__PURE__ */ new Set();
6591
6728
  for (const [category, agg] of aggregated) {
6592
6729
  visitedCategories.add(category);
6593
- const baselineCategory = baseline.metrics[category];
6594
- const baselineViolationIds = new Set(baselineCategory?.violationIds ?? []);
6595
- const baselineValue = baselineCategory?.value ?? 0;
6596
- const classified = classifyViolations(agg.violations, baselineViolationIds);
6597
- newViolations.push(...classified.newViolations);
6598
- preExisting.push(...classified.preExisting);
6599
- const currentViolationIds = new Set(agg.violations.map((v) => v.id));
6600
- resolvedViolations.push(...findResolvedViolations(baselineCategory, currentViolationIds));
6601
- if (baselineCategory && agg.value > baselineValue) {
6602
- regressions.push({
6603
- category,
6604
- baselineValue,
6605
- currentValue: agg.value,
6606
- delta: agg.value - baselineValue
6607
- });
6608
- }
6730
+ diffCategory(category, agg, baseline.metrics[category], acc);
6609
6731
  }
6610
- resolvedViolations.push(...collectOrphanedBaselineViolations(baseline, visitedCategories));
6732
+ acc.resolvedViolations.push(...collectOrphanedBaselineViolations(baseline, visitedCategories));
6611
6733
  return {
6612
- passed: newViolations.length === 0 && regressions.length === 0,
6613
- newViolations,
6614
- resolvedViolations,
6615
- preExisting,
6616
- regressions
6734
+ passed: acc.newViolations.length === 0 && acc.regressions.length === 0,
6735
+ newViolations: acc.newViolations,
6736
+ resolvedViolations: acc.resolvedViolations,
6737
+ preExisting: acc.preExisting,
6738
+ regressions: acc.regressions
6617
6739
  };
6618
6740
  }
6619
6741
 
6620
6742
  // src/architecture/config.ts
6621
- function resolveThresholds2(scope, config) {
6743
+ function mergeThresholdCategory(projectValue2, moduleValue) {
6744
+ if (projectValue2 !== void 0 && typeof projectValue2 === "object" && !Array.isArray(projectValue2) && typeof moduleValue === "object" && !Array.isArray(moduleValue)) {
6745
+ return {
6746
+ ...projectValue2,
6747
+ ...moduleValue
6748
+ };
6749
+ }
6750
+ return moduleValue;
6751
+ }
6752
+ function resolveThresholds3(scope, config) {
6622
6753
  const projectThresholds = {};
6623
6754
  for (const [key, val] of Object.entries(config.thresholds)) {
6624
6755
  projectThresholds[key] = typeof val === "object" && val !== null && !Array.isArray(val) ? { ...val } : val;
@@ -6633,14 +6764,7 @@ function resolveThresholds2(scope, config) {
6633
6764
  const merged = { ...projectThresholds };
6634
6765
  for (const [category, moduleValue] of Object.entries(moduleOverrides)) {
6635
6766
  const projectValue2 = projectThresholds[category];
6636
- if (projectValue2 !== void 0 && typeof projectValue2 === "object" && !Array.isArray(projectValue2) && typeof moduleValue === "object" && !Array.isArray(moduleValue)) {
6637
- merged[category] = {
6638
- ...projectValue2,
6639
- ...moduleValue
6640
- };
6641
- } else {
6642
- merged[category] = moduleValue;
6643
- }
6767
+ merged[category] = mergeThresholdCategory(projectValue2, moduleValue);
6644
6768
  }
6645
6769
  return merged;
6646
6770
  }
@@ -7450,18 +7574,10 @@ var PredictionEngine = class {
7450
7574
  */
7451
7575
  predict(options) {
7452
7576
  const opts = this.resolveOptions(options);
7453
- const timeline = this.timelineManager.load();
7454
- const snapshots = timeline.snapshots;
7455
- if (snapshots.length < 3) {
7456
- throw new Error(
7457
- `PredictionEngine requires at least 3 snapshots, got ${snapshots.length}. Run "harness snapshot" to capture more data points.`
7458
- );
7459
- }
7577
+ const snapshots = this.loadValidatedSnapshots();
7460
7578
  const thresholds = this.resolveThresholds(opts);
7461
7579
  const categoriesToProcess = opts.categories ?? [...ALL_CATEGORIES2];
7462
- const firstDate = new Date(snapshots[0].capturedAt).getTime();
7463
- const lastSnapshot = snapshots[snapshots.length - 1];
7464
- const currentT = (new Date(lastSnapshot.capturedAt).getTime() - firstDate) / (7 * 24 * 60 * 60 * 1e3);
7580
+ const { firstDate, lastSnapshot, currentT } = this.computeTimeOffsets(snapshots);
7465
7581
  const baselines = this.computeBaselines(
7466
7582
  categoriesToProcess,
7467
7583
  thresholds,
@@ -7472,27 +7588,32 @@ var PredictionEngine = class {
7472
7588
  );
7473
7589
  const specImpacts = this.computeSpecImpacts(opts);
7474
7590
  const categories = this.computeAdjustedForecasts(baselines, thresholds, specImpacts, currentT);
7475
- const warnings = this.generateWarnings(
7476
- categories,
7477
- opts.horizon
7478
- );
7479
- const stabilityForecast = this.computeStabilityForecast(
7480
- categories,
7481
- thresholds,
7482
- snapshots
7483
- );
7591
+ const adjustedCategories = categories;
7484
7592
  return {
7485
7593
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
7486
7594
  snapshotsUsed: snapshots.length,
7487
- timelineRange: {
7488
- from: snapshots[0].capturedAt,
7489
- to: lastSnapshot.capturedAt
7490
- },
7491
- stabilityForecast,
7492
- categories,
7493
- warnings
7595
+ timelineRange: { from: snapshots[0].capturedAt, to: lastSnapshot.capturedAt },
7596
+ stabilityForecast: this.computeStabilityForecast(adjustedCategories, thresholds, snapshots),
7597
+ categories: adjustedCategories,
7598
+ warnings: this.generateWarnings(adjustedCategories, opts.horizon)
7494
7599
  };
7495
7600
  }
7601
+ loadValidatedSnapshots() {
7602
+ const timeline = this.timelineManager.load();
7603
+ const snapshots = timeline.snapshots;
7604
+ if (snapshots.length < 3) {
7605
+ throw new Error(
7606
+ `PredictionEngine requires at least 3 snapshots, got ${snapshots.length}. Run "harness snapshot" to capture more data points.`
7607
+ );
7608
+ }
7609
+ return snapshots;
7610
+ }
7611
+ computeTimeOffsets(snapshots) {
7612
+ const firstDate = new Date(snapshots[0].capturedAt).getTime();
7613
+ const lastSnapshot = snapshots[snapshots.length - 1];
7614
+ const currentT = (new Date(lastSnapshot.capturedAt).getTime() - firstDate) / (7 * 24 * 60 * 60 * 1e3);
7615
+ return { firstDate, lastSnapshot, currentT };
7616
+ }
7496
7617
  // --- Private helpers ---
7497
7618
  resolveOptions(options) {
7498
7619
  return {
@@ -7659,31 +7780,40 @@ var PredictionEngine = class {
7659
7780
  for (const category of ALL_CATEGORIES2) {
7660
7781
  const af = categories[category];
7661
7782
  if (!af) continue;
7662
- const forecast = af.adjusted;
7663
- const crossing = forecast.thresholdCrossingWeeks;
7664
- if (crossing === null || crossing <= 0) continue;
7665
- let severity = null;
7666
- if (crossing <= criticalWindow && (forecast.confidence === "high" || forecast.confidence === "medium")) {
7667
- severity = "critical";
7668
- } else if (crossing <= warningWindow && (forecast.confidence === "high" || forecast.confidence === "medium")) {
7669
- severity = "warning";
7670
- } else if (crossing <= horizon) {
7671
- severity = "info";
7672
- }
7673
- if (severity) {
7674
- const contributingNames = af.contributingFeatures.map((f) => f.name);
7675
- warnings.push({
7676
- severity,
7677
- category,
7678
- message: `${category} projected to exceed threshold (~${crossing}w, ${forecast.confidence} confidence)`,
7679
- weeksUntil: crossing,
7680
- confidence: forecast.confidence,
7681
- contributingFeatures: contributingNames
7682
- });
7683
- }
7783
+ const warning = this.buildCategoryWarning(
7784
+ category,
7785
+ af,
7786
+ criticalWindow,
7787
+ warningWindow,
7788
+ horizon
7789
+ );
7790
+ if (warning) warnings.push(warning);
7684
7791
  }
7685
7792
  return warnings;
7686
7793
  }
7794
+ buildCategoryWarning(category, af, criticalWindow, warningWindow, horizon) {
7795
+ const forecast = af.adjusted;
7796
+ const crossing = forecast.thresholdCrossingWeeks;
7797
+ if (crossing === null || crossing <= 0) return null;
7798
+ const isHighConfidence = forecast.confidence === "high" || forecast.confidence === "medium";
7799
+ let severity = null;
7800
+ if (crossing <= criticalWindow && isHighConfidence) {
7801
+ severity = "critical";
7802
+ } else if (crossing <= warningWindow && isHighConfidence) {
7803
+ severity = "warning";
7804
+ } else if (crossing <= horizon) {
7805
+ severity = "info";
7806
+ }
7807
+ if (!severity) return null;
7808
+ return {
7809
+ severity,
7810
+ category,
7811
+ message: `${category} projected to exceed threshold (~${crossing}w, ${forecast.confidence} confidence)`,
7812
+ weeksUntil: crossing,
7813
+ confidence: forecast.confidence,
7814
+ contributingFeatures: af.contributingFeatures.map((f) => f.name)
7815
+ };
7816
+ }
7687
7817
  /**
7688
7818
  * Compute composite stability forecast by projecting per-category values
7689
7819
  * forward and computing stability scores at each horizon.
@@ -7752,14 +7882,9 @@ var PredictionEngine = class {
7752
7882
  const raw = fs5.readFileSync(roadmapPath, "utf-8");
7753
7883
  const parseResult = parseRoadmap(raw);
7754
7884
  if (!parseResult.ok) return null;
7755
- const features = [];
7756
- for (const milestone of parseResult.value.milestones) {
7757
- for (const feature of milestone.features) {
7758
- if (feature.status === "planned" || feature.status === "in-progress") {
7759
- features.push({ name: feature.name, spec: feature.spec });
7760
- }
7761
- }
7762
- }
7885
+ const features = parseResult.value.milestones.flatMap(
7886
+ (m) => m.features.filter((f) => f.status === "planned" || f.status === "in-progress").map((f) => ({ name: f.name, spec: f.spec }))
7887
+ );
7763
7888
  if (features.length === 0) return null;
7764
7889
  return this.estimator.estimateAll(features);
7765
7890
  } catch {
@@ -8410,7 +8535,7 @@ async function saveState(projectPath, state, stream, session) {
8410
8535
  // src/state/learnings-content.ts
8411
8536
  var fs11 = __toESM(require("fs"));
8412
8537
  var path8 = __toESM(require("path"));
8413
- var crypto = __toESM(require("crypto"));
8538
+ var crypto2 = __toESM(require("crypto"));
8414
8539
  function parseFrontmatter2(line) {
8415
8540
  const match = line.match(/^<!--\s+hash:([a-f0-9]+)(?:\s+tags:([^\s]+))?\s+-->/);
8416
8541
  if (!match) return null;
@@ -8438,7 +8563,7 @@ function extractIndexEntry(entry) {
8438
8563
  };
8439
8564
  }
8440
8565
  function computeEntryHash(text) {
8441
- return crypto.createHash("sha256").update(text).digest("hex").slice(0, 8);
8566
+ return crypto2.createHash("sha256").update(text).digest("hex").slice(0, 8);
8442
8567
  }
8443
8568
  function normalizeLearningContent(text) {
8444
8569
  let normalized = text;
@@ -8453,7 +8578,7 @@ function normalizeLearningContent(text) {
8453
8578
  return normalized;
8454
8579
  }
8455
8580
  function computeContentHash(text) {
8456
- return crypto.createHash("sha256").update(text).digest("hex").slice(0, 16);
8581
+ return crypto2.createHash("sha256").update(text).digest("hex").slice(0, 16);
8457
8582
  }
8458
8583
  function loadContentHashes(stateDir) {
8459
8584
  const hashesPath = path8.join(stateDir, CONTENT_HASHES_FILE);
@@ -8593,7 +8718,7 @@ async function loadRelevantLearnings(projectPath, skillName, stream, session) {
8593
8718
  // src/state/learnings.ts
8594
8719
  var fs13 = __toESM(require("fs"));
8595
8720
  var path10 = __toESM(require("path"));
8596
- var crypto2 = __toESM(require("crypto"));
8721
+ var crypto3 = __toESM(require("crypto"));
8597
8722
  async function appendLearning(projectPath, learning, skillName, outcome, stream, session) {
8598
8723
  try {
8599
8724
  const dirResult = await getStateDir(projectPath, stream, session);
@@ -8630,7 +8755,7 @@ async function appendLearning(projectPath, learning, skillName, outcome, stream,
8630
8755
  } else {
8631
8756
  bulletLine = `- **${timestamp}:** ${learning}`;
8632
8757
  }
8633
- const hash = crypto2.createHash("sha256").update(bulletLine).digest("hex").slice(0, 8);
8758
+ const hash = crypto3.createHash("sha256").update(bulletLine).digest("hex").slice(0, 8);
8634
8759
  const tagsStr = fmTags.length > 0 ? ` tags:${fmTags.join(",")}` : "";
8635
8760
  const frontmatter = `<!-- hash:${hash}${tagsStr} -->`;
8636
8761
  const entry = `
@@ -9341,7 +9466,7 @@ async function updateSessionEntryStatus(projectPath, sessionSlug, section, entry
9341
9466
  }
9342
9467
  function generateEntryId() {
9343
9468
  const timestamp = Date.now().toString(36);
9344
- const random = Math.random().toString(36).substring(2, 8);
9469
+ const random = Buffer.from(crypto.getRandomValues(new Uint8Array(4))).toString("hex");
9345
9470
  return `${timestamp}-${random}`;
9346
9471
  }
9347
9472
 
@@ -9486,15 +9611,25 @@ async function loadEvents(projectPath, options) {
9486
9611
  );
9487
9612
  }
9488
9613
  }
9614
+ function phaseTransitionFields(data) {
9615
+ return {
9616
+ from: data?.from ?? "?",
9617
+ to: data?.to ?? "?",
9618
+ suffix: data?.taskCount ? ` (${data.taskCount} tasks)` : ""
9619
+ };
9620
+ }
9489
9621
  function formatPhaseTransition(event) {
9490
9622
  const data = event.data;
9491
- const suffix = data?.taskCount ? ` (${data.taskCount} tasks)` : "";
9492
- return `phase: ${data?.from ?? "?"} -> ${data?.to ?? "?"}${suffix}`;
9623
+ const { from, to, suffix } = phaseTransitionFields(data);
9624
+ return `phase: ${from} -> ${to}${suffix}`;
9625
+ }
9626
+ function formatGateChecks(checks) {
9627
+ return checks?.map((c) => `${c.name} ${c.passed ? "Y" : "N"}`).join(", ");
9493
9628
  }
9494
9629
  function formatGateResult(event) {
9495
9630
  const data = event.data;
9496
9631
  const status = data?.passed ? "passed" : "failed";
9497
- const checks = data?.checks?.map((c) => `${c.name} ${c.passed ? "Y" : "N"}`).join(", ");
9632
+ const checks = formatGateChecks(data?.checks);
9498
9633
  return checks ? `gate: ${status} (${checks})` : `gate: ${status}`;
9499
9634
  }
9500
9635
  function formatHandoffDetail(event) {
@@ -9770,34 +9905,36 @@ function resolveRuleSeverity(ruleId, defaultSeverity, overrides, strict) {
9770
9905
  // src/security/stack-detector.ts
9771
9906
  var fs22 = __toESM(require("fs"));
9772
9907
  var path19 = __toESM(require("path"));
9773
- function detectStack(projectRoot) {
9774
- const stacks = [];
9908
+ function nodeSubStacks(allDeps) {
9909
+ const found = [];
9910
+ if (allDeps.react || allDeps["react-dom"]) found.push("react");
9911
+ if (allDeps.express) found.push("express");
9912
+ if (allDeps.koa) found.push("koa");
9913
+ if (allDeps.fastify) found.push("fastify");
9914
+ if (allDeps.next) found.push("next");
9915
+ if (allDeps.vue) found.push("vue");
9916
+ if (allDeps.angular || allDeps["@angular/core"]) found.push("angular");
9917
+ return found;
9918
+ }
9919
+ function detectNodeStacks(projectRoot) {
9775
9920
  const pkgJsonPath = path19.join(projectRoot, "package.json");
9776
- if (fs22.existsSync(pkgJsonPath)) {
9777
- stacks.push("node");
9778
- try {
9779
- const pkgJson = JSON.parse(fs22.readFileSync(pkgJsonPath, "utf-8"));
9780
- const allDeps = {
9781
- ...pkgJson.dependencies,
9782
- ...pkgJson.devDependencies
9783
- };
9784
- if (allDeps.react || allDeps["react-dom"]) stacks.push("react");
9785
- if (allDeps.express) stacks.push("express");
9786
- if (allDeps.koa) stacks.push("koa");
9787
- if (allDeps.fastify) stacks.push("fastify");
9788
- if (allDeps.next) stacks.push("next");
9789
- if (allDeps.vue) stacks.push("vue");
9790
- if (allDeps.angular || allDeps["@angular/core"]) stacks.push("angular");
9791
- } catch {
9792
- }
9921
+ if (!fs22.existsSync(pkgJsonPath)) return [];
9922
+ const stacks = ["node"];
9923
+ try {
9924
+ const pkgJson = JSON.parse(fs22.readFileSync(pkgJsonPath, "utf-8"));
9925
+ const allDeps = { ...pkgJson.dependencies, ...pkgJson.devDependencies };
9926
+ stacks.push(...nodeSubStacks(allDeps));
9927
+ } catch {
9793
9928
  }
9794
- const goModPath = path19.join(projectRoot, "go.mod");
9795
- if (fs22.existsSync(goModPath)) {
9929
+ return stacks;
9930
+ }
9931
+ function detectStack(projectRoot) {
9932
+ const stacks = [...detectNodeStacks(projectRoot)];
9933
+ if (fs22.existsSync(path19.join(projectRoot, "go.mod"))) {
9796
9934
  stacks.push("go");
9797
9935
  }
9798
- const requirementsPath = path19.join(projectRoot, "requirements.txt");
9799
- const pyprojectPath = path19.join(projectRoot, "pyproject.toml");
9800
- if (fs22.existsSync(requirementsPath) || fs22.existsSync(pyprojectPath)) {
9936
+ const hasPython = fs22.existsSync(path19.join(projectRoot, "requirements.txt")) || fs22.existsSync(path19.join(projectRoot, "pyproject.toml"));
9937
+ if (hasPython) {
9801
9938
  stacks.push("python");
9802
9939
  }
9803
9940
  return stacks;
@@ -10641,6 +10778,56 @@ var SecurityScanner = class {
10641
10778
  });
10642
10779
  return this.scanLinesWithRules(lines, applicableRules, filePath, startLine);
10643
10780
  }
10781
+ /** Build a finding for a suppression comment that is missing its justification. */
10782
+ buildSuppressionFinding(rule, filePath, lineNumber, line) {
10783
+ return {
10784
+ ruleId: rule.id,
10785
+ ruleName: rule.name,
10786
+ category: rule.category,
10787
+ severity: this.config.strict ? "error" : "warning",
10788
+ confidence: "high",
10789
+ file: filePath,
10790
+ line: lineNumber,
10791
+ match: line.trim(),
10792
+ context: line,
10793
+ message: `Suppression of ${rule.id} requires justification: // harness-ignore ${rule.id}: <reason>`,
10794
+ remediation: `Add justification after colon: // harness-ignore ${rule.id}: false positive because ...`
10795
+ };
10796
+ }
10797
+ /** Check one line against a rule's patterns; return a finding or null. */
10798
+ matchRuleLine(rule, resolved, filePath, lineNumber, line) {
10799
+ for (const pattern of rule.patterns) {
10800
+ pattern.lastIndex = 0;
10801
+ if (!pattern.test(line)) continue;
10802
+ return {
10803
+ ruleId: rule.id,
10804
+ ruleName: rule.name,
10805
+ category: rule.category,
10806
+ severity: resolved,
10807
+ confidence: rule.confidence,
10808
+ file: filePath,
10809
+ line: lineNumber,
10810
+ match: line.trim(),
10811
+ context: line,
10812
+ message: rule.message,
10813
+ remediation: rule.remediation,
10814
+ ...rule.references ? { references: rule.references } : {}
10815
+ };
10816
+ }
10817
+ return null;
10818
+ }
10819
+ /** Scan a single line against a resolved rule; push any findings into the array. */
10820
+ scanLineForRule(rule, resolved, line, lineNumber, filePath, findings) {
10821
+ const suppressionMatch = parseHarnessIgnore(line, rule.id);
10822
+ if (suppressionMatch) {
10823
+ if (!suppressionMatch.justification) {
10824
+ findings.push(this.buildSuppressionFinding(rule, filePath, lineNumber, line));
10825
+ }
10826
+ return;
10827
+ }
10828
+ const finding = this.matchRuleLine(rule, resolved, filePath, lineNumber, line);
10829
+ if (finding) findings.push(finding);
10830
+ }
10644
10831
  /**
10645
10832
  * Core scanning loop shared by scanContent and scanContentForFile.
10646
10833
  * Evaluates each rule against each line, handling suppression (FP gate)
@@ -10657,46 +10844,7 @@ var SecurityScanner = class {
10657
10844
  );
10658
10845
  if (resolved === "off") continue;
10659
10846
  for (let i = 0; i < lines.length; i++) {
10660
- const line = lines[i] ?? "";
10661
- const suppressionMatch = parseHarnessIgnore(line, rule.id);
10662
- if (suppressionMatch) {
10663
- if (!suppressionMatch.justification) {
10664
- findings.push({
10665
- ruleId: rule.id,
10666
- ruleName: rule.name,
10667
- category: rule.category,
10668
- severity: this.config.strict ? "error" : "warning",
10669
- confidence: "high",
10670
- file: filePath,
10671
- line: startLine + i,
10672
- match: line.trim(),
10673
- context: line,
10674
- message: `Suppression of ${rule.id} requires justification: // harness-ignore ${rule.id}: <reason>`,
10675
- remediation: `Add justification after colon: // harness-ignore ${rule.id}: false positive because ...`
10676
- });
10677
- }
10678
- continue;
10679
- }
10680
- for (const pattern of rule.patterns) {
10681
- pattern.lastIndex = 0;
10682
- if (pattern.test(line)) {
10683
- findings.push({
10684
- ruleId: rule.id,
10685
- ruleName: rule.name,
10686
- category: rule.category,
10687
- severity: resolved,
10688
- confidence: rule.confidence,
10689
- file: filePath,
10690
- line: startLine + i,
10691
- match: line.trim(),
10692
- context: line,
10693
- message: rule.message,
10694
- remediation: rule.remediation,
10695
- ...rule.references ? { references: rule.references } : {}
10696
- });
10697
- break;
10698
- }
10699
- }
10847
+ this.scanLineForRule(rule, resolved, lines[i] ?? "", startLine + i, filePath, findings);
10700
10848
  }
10701
10849
  }
10702
10850
  return findings;
@@ -12114,39 +12262,32 @@ function extractConventionRules(bundle) {
12114
12262
  }
12115
12263
  }
12116
12264
  }
12117
- return rules;
12118
- }
12119
- function findMissingJsDoc(bundle) {
12120
- const missing = [];
12121
- for (const cf of bundle.changedFiles) {
12122
- const lines = cf.content.split("\n");
12123
- for (let i = 0; i < lines.length; i++) {
12124
- const line = lines[i];
12125
- const exportMatch = line.match(
12126
- /export\s+(?:async\s+)?(?:function|const|class|interface|type)\s+(\w+)/
12127
- );
12128
- if (exportMatch) {
12129
- let hasJsDoc = false;
12130
- for (let j = i - 1; j >= 0; j--) {
12131
- const prev = lines[j].trim();
12132
- if (prev === "") continue;
12133
- if (prev.endsWith("*/")) {
12134
- hasJsDoc = true;
12135
- }
12136
- break;
12137
- }
12138
- if (!hasJsDoc) {
12139
- missing.push({
12140
- file: cf.path,
12141
- line: i + 1,
12142
- exportName: exportMatch[1]
12143
- });
12144
- }
12145
- }
12146
- }
12147
- }
12265
+ return rules;
12266
+ }
12267
+ var EXPORT_RE = /export\s+(?:async\s+)?(?:function|const|class|interface|type)\s+(\w+)/;
12268
+ function hasPrecedingJsDoc(lines, i) {
12269
+ for (let j = i - 1; j >= 0; j--) {
12270
+ const prev = lines[j].trim();
12271
+ if (prev === "") continue;
12272
+ return prev.endsWith("*/");
12273
+ }
12274
+ return false;
12275
+ }
12276
+ function scanFileForMissingJsDoc(filePath, lines) {
12277
+ const missing = [];
12278
+ for (let i = 0; i < lines.length; i++) {
12279
+ const exportMatch = lines[i].match(EXPORT_RE);
12280
+ if (exportMatch && !hasPrecedingJsDoc(lines, i)) {
12281
+ missing.push({ file: filePath, line: i + 1, exportName: exportMatch[1] });
12282
+ }
12283
+ }
12148
12284
  return missing;
12149
12285
  }
12286
+ function findMissingJsDoc(bundle) {
12287
+ return bundle.changedFiles.flatMap(
12288
+ (cf) => scanFileForMissingJsDoc(cf.path, cf.content.split("\n"))
12289
+ );
12290
+ }
12150
12291
  function checkMissingJsDoc(bundle, rules) {
12151
12292
  const jsDocRule = rules.find((r) => r.text.toLowerCase().includes("jsdoc"));
12152
12293
  if (!jsDocRule) return [];
@@ -12211,29 +12352,27 @@ function checkChangeTypeSpecific(bundle) {
12211
12352
  return [];
12212
12353
  }
12213
12354
  }
12355
+ function checkFileResultTypeConvention(cf, bundle, rule) {
12356
+ const hasTryCatch = cf.content.includes("try {") || cf.content.includes("try{");
12357
+ const usesResult = cf.content.includes("Result<") || cf.content.includes("Result >") || cf.content.includes(": Result");
12358
+ if (!hasTryCatch || usesResult) return null;
12359
+ return {
12360
+ id: makeFindingId("compliance", cf.path, 1, "try-catch not Result"),
12361
+ file: cf.path,
12362
+ lineRange: [1, cf.lines],
12363
+ domain: "compliance",
12364
+ severity: "suggestion",
12365
+ title: "Fallible operation uses try/catch instead of Result type",
12366
+ rationale: `Convention requires using Result type for fallible operations (from ${rule.source}).`,
12367
+ suggestion: "Refactor error handling to use the Result type pattern.",
12368
+ evidence: [`changeType: ${bundle.changeType}`, `Convention rule: "${rule.text}"`],
12369
+ validatedBy: "heuristic"
12370
+ };
12371
+ }
12214
12372
  function checkResultTypeConvention(bundle, rules) {
12215
12373
  const resultTypeRule = rules.find((r) => r.text.toLowerCase().includes("result type"));
12216
12374
  if (!resultTypeRule) return [];
12217
- const findings = [];
12218
- for (const cf of bundle.changedFiles) {
12219
- const hasTryCatch = cf.content.includes("try {") || cf.content.includes("try{");
12220
- const usesResult = cf.content.includes("Result<") || cf.content.includes("Result >") || cf.content.includes(": Result");
12221
- if (hasTryCatch && !usesResult) {
12222
- findings.push({
12223
- id: makeFindingId("compliance", cf.path, 1, "try-catch not Result"),
12224
- file: cf.path,
12225
- lineRange: [1, cf.lines],
12226
- domain: "compliance",
12227
- severity: "suggestion",
12228
- title: "Fallible operation uses try/catch instead of Result type",
12229
- rationale: `Convention requires using Result type for fallible operations (from ${resultTypeRule.source}).`,
12230
- suggestion: "Refactor error handling to use the Result type pattern.",
12231
- evidence: [`changeType: ${bundle.changeType}`, `Convention rule: "${resultTypeRule.text}"`],
12232
- validatedBy: "heuristic"
12233
- });
12234
- }
12235
- }
12236
- return findings;
12375
+ return bundle.changedFiles.map((cf) => checkFileResultTypeConvention(cf, bundle, resultTypeRule)).filter((f) => f !== null);
12237
12376
  }
12238
12377
  function runComplianceAgent(bundle) {
12239
12378
  const rules = extractConventionRules(bundle);
@@ -12259,53 +12398,58 @@ var BUG_DETECTION_DESCRIPTOR = {
12259
12398
  "Test coverage \u2014 tests for happy path, error paths, and edge cases"
12260
12399
  ]
12261
12400
  };
12401
+ function hasPrecedingZeroCheck(lines, i) {
12402
+ const preceding = lines.slice(Math.max(0, i - 3), i).join("\n");
12403
+ return preceding.includes("=== 0") || preceding.includes("!== 0") || preceding.includes("== 0") || preceding.includes("!= 0");
12404
+ }
12262
12405
  function detectDivisionByZero(bundle) {
12263
12406
  const findings = [];
12264
12407
  for (const cf of bundle.changedFiles) {
12265
12408
  const lines = cf.content.split("\n");
12266
12409
  for (let i = 0; i < lines.length; i++) {
12267
12410
  const line = lines[i];
12268
- if (line.match(/[^=!<>]\s*\/\s*[a-zA-Z_]\w*/) && !line.includes("//")) {
12269
- const preceding = lines.slice(Math.max(0, i - 3), i).join("\n");
12270
- if (!preceding.includes("=== 0") && !preceding.includes("!== 0") && !preceding.includes("== 0") && !preceding.includes("!= 0")) {
12271
- findings.push({
12272
- id: makeFindingId("bug", cf.path, i + 1, "division by zero"),
12273
- file: cf.path,
12274
- lineRange: [i + 1, i + 1],
12275
- domain: "bug",
12276
- severity: "important",
12277
- title: "Potential division by zero without guard",
12278
- rationale: "Division operation found without a preceding zero check on the divisor. This can cause Infinity or NaN at runtime.",
12279
- suggestion: "Add a check for zero before dividing, or use a safe division utility.",
12280
- evidence: [`Line ${i + 1}: ${line.trim()}`],
12281
- validatedBy: "heuristic"
12282
- });
12283
- }
12284
- }
12411
+ if (!line.match(/[^=!<>]\s*\/\s*[a-zA-Z_]\w*/) || line.includes("//")) continue;
12412
+ if (hasPrecedingZeroCheck(lines, i)) continue;
12413
+ findings.push({
12414
+ id: makeFindingId("bug", cf.path, i + 1, "division by zero"),
12415
+ file: cf.path,
12416
+ lineRange: [i + 1, i + 1],
12417
+ domain: "bug",
12418
+ severity: "important",
12419
+ title: "Potential division by zero without guard",
12420
+ rationale: "Division operation found without a preceding zero check on the divisor. This can cause Infinity or NaN at runtime.",
12421
+ suggestion: "Add a check for zero before dividing, or use a safe division utility.",
12422
+ evidence: [`Line ${i + 1}: ${line.trim()}`],
12423
+ validatedBy: "heuristic"
12424
+ });
12285
12425
  }
12286
12426
  }
12287
12427
  return findings;
12288
12428
  }
12429
+ function isEmptyCatch(lines, i) {
12430
+ const line = lines[i];
12431
+ if (line.match(/catch\s*\([^)]*\)\s*\{\s*\}/)) return true;
12432
+ return line.match(/catch\s*\([^)]*\)\s*\{/) !== null && i + 1 < lines.length && lines[i + 1].trim() === "}";
12433
+ }
12289
12434
  function detectEmptyCatch(bundle) {
12290
12435
  const findings = [];
12291
12436
  for (const cf of bundle.changedFiles) {
12292
12437
  const lines = cf.content.split("\n");
12293
12438
  for (let i = 0; i < lines.length; i++) {
12439
+ if (!isEmptyCatch(lines, i)) continue;
12294
12440
  const line = lines[i];
12295
- if (line.match(/catch\s*\([^)]*\)\s*\{\s*\}/) || line.match(/catch\s*\([^)]*\)\s*\{/) && i + 1 < lines.length && lines[i + 1].trim() === "}") {
12296
- findings.push({
12297
- id: makeFindingId("bug", cf.path, i + 1, "empty catch block"),
12298
- file: cf.path,
12299
- lineRange: [i + 1, i + 2],
12300
- domain: "bug",
12301
- severity: "important",
12302
- title: "Empty catch block silently swallows error",
12303
- rationale: "Catching an error without handling, logging, or re-throwing it hides failures and makes debugging difficult.",
12304
- suggestion: "Log the error, re-throw it, or handle it explicitly. If intentionally ignoring, add a comment explaining why.",
12305
- evidence: [`Line ${i + 1}: ${line.trim()}`],
12306
- validatedBy: "heuristic"
12307
- });
12308
- }
12441
+ findings.push({
12442
+ id: makeFindingId("bug", cf.path, i + 1, "empty catch block"),
12443
+ file: cf.path,
12444
+ lineRange: [i + 1, i + 2],
12445
+ domain: "bug",
12446
+ severity: "important",
12447
+ title: "Empty catch block silently swallows error",
12448
+ rationale: "Catching an error without handling, logging, or re-throwing it hides failures and makes debugging difficult.",
12449
+ suggestion: "Log the error, re-throw it, or handle it explicitly. If intentionally ignoring, add a comment explaining why.",
12450
+ evidence: [`Line ${i + 1}: ${line.trim()}`],
12451
+ validatedBy: "heuristic"
12452
+ });
12309
12453
  }
12310
12454
  }
12311
12455
  return findings;
@@ -12363,34 +12507,102 @@ var SECRET_PATTERNS = [
12363
12507
  ];
12364
12508
  var SQL_CONCAT_PATTERN = /(?:SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER)\s+.*?\+\s*\w+|`[^`]*\$\{[^}]*\}[^`]*(?:SELECT|INSERT|UPDATE|DELETE|WHERE)/i;
12365
12509
  var SHELL_EXEC_PATTERN = /(?:exec|execSync|spawn|spawnSync)\s*\(\s*`[^`]*\$\{/;
12510
+ function makeEvalFinding(file, lineNum, line) {
12511
+ return {
12512
+ id: makeFindingId("security", file, lineNum, "eval usage CWE-94"),
12513
+ file,
12514
+ lineRange: [lineNum, lineNum],
12515
+ domain: "security",
12516
+ severity: "critical",
12517
+ title: `Dangerous ${"eval"}() or new ${"Function"}() usage`,
12518
+ rationale: `${"eval"}() and new ${"Function"}() execute arbitrary code. If user input reaches these calls, it enables Remote Code Execution (CWE-94).`,
12519
+ suggestion: "Replace eval/Function with a safe alternative (JSON.parse for data, a sandboxed evaluator for expressions).",
12520
+ evidence: [`Line ${lineNum}: ${line.trim()}`],
12521
+ validatedBy: "heuristic",
12522
+ cweId: "CWE-94",
12523
+ owaspCategory: "A03:2021 Injection",
12524
+ confidence: "high",
12525
+ remediation: "Replace eval/Function with a safe alternative (JSON.parse for data, a sandboxed evaluator for expressions).",
12526
+ references: [
12527
+ "https://cwe.mitre.org/data/definitions/94.html",
12528
+ "https://owasp.org/Top10/A03_2021-Injection/"
12529
+ ]
12530
+ };
12531
+ }
12532
+ function makeSecretFinding(file, lineNum) {
12533
+ return {
12534
+ id: makeFindingId("security", file, lineNum, "hardcoded secret CWE-798"),
12535
+ file,
12536
+ lineRange: [lineNum, lineNum],
12537
+ domain: "security",
12538
+ severity: "critical",
12539
+ title: "Hardcoded secret or API key detected",
12540
+ rationale: "Hardcoded secrets in source code can be extracted from version history even after removal. Use environment variables or a secrets manager (CWE-798).",
12541
+ suggestion: "Move the secret to an environment variable and access it via process.env.",
12542
+ evidence: [`Line ${lineNum}: [secret detected \u2014 value redacted]`],
12543
+ validatedBy: "heuristic",
12544
+ cweId: "CWE-798",
12545
+ owaspCategory: "A07:2021 Identification and Authentication Failures",
12546
+ confidence: "high",
12547
+ remediation: "Move the secret to an environment variable and access it via process.env.",
12548
+ references: [
12549
+ "https://cwe.mitre.org/data/definitions/798.html",
12550
+ "https://owasp.org/Top10/A07_2021-Identification_and_Authentication_Failures/"
12551
+ ]
12552
+ };
12553
+ }
12554
+ function makeSqlFinding(file, lineNum, line) {
12555
+ return {
12556
+ id: makeFindingId("security", file, lineNum, "SQL injection CWE-89"),
12557
+ file,
12558
+ lineRange: [lineNum, lineNum],
12559
+ domain: "security",
12560
+ severity: "critical",
12561
+ title: "Potential SQL injection via string concatenation",
12562
+ rationale: "Building SQL queries with string concatenation or template literals allows attackers to inject malicious SQL (CWE-89).",
12563
+ suggestion: "Use parameterized queries or a query builder (e.g., Knex, Prisma) instead of string concatenation.",
12564
+ evidence: [`Line ${lineNum}: ${line.trim()}`],
12565
+ validatedBy: "heuristic",
12566
+ cweId: "CWE-89",
12567
+ owaspCategory: "A03:2021 Injection",
12568
+ confidence: "high",
12569
+ remediation: "Use parameterized queries or a query builder (e.g., Knex, Prisma) instead of string concatenation.",
12570
+ references: [
12571
+ "https://cwe.mitre.org/data/definitions/89.html",
12572
+ "https://owasp.org/Top10/A03_2021-Injection/"
12573
+ ]
12574
+ };
12575
+ }
12576
+ function makeCommandFinding(file, lineNum, line) {
12577
+ return {
12578
+ id: makeFindingId("security", file, lineNum, "command injection CWE-78"),
12579
+ file,
12580
+ lineRange: [lineNum, lineNum],
12581
+ domain: "security",
12582
+ severity: "critical",
12583
+ title: "Potential command injection via shell exec with interpolation",
12584
+ rationale: "Using exec/spawn with template literal interpolation allows attackers to inject shell commands (CWE-78).",
12585
+ suggestion: "Use execFile or spawn with an arguments array instead of shell string interpolation.",
12586
+ evidence: [`Line ${lineNum}: ${line.trim()}`],
12587
+ validatedBy: "heuristic",
12588
+ cweId: "CWE-78",
12589
+ owaspCategory: "A03:2021 Injection",
12590
+ confidence: "high",
12591
+ remediation: "Use execFile or spawn with an arguments array instead of shell string interpolation.",
12592
+ references: [
12593
+ "https://cwe.mitre.org/data/definitions/78.html",
12594
+ "https://owasp.org/Top10/A03_2021-Injection/"
12595
+ ]
12596
+ };
12597
+ }
12366
12598
  function detectEvalUsage(bundle) {
12367
12599
  const findings = [];
12368
12600
  for (const cf of bundle.changedFiles) {
12369
12601
  const lines = cf.content.split("\n");
12370
12602
  for (let i = 0; i < lines.length; i++) {
12371
12603
  const line = lines[i];
12372
- if (EVAL_PATTERN.test(line)) {
12373
- findings.push({
12374
- id: makeFindingId("security", cf.path, i + 1, "eval usage CWE-94"),
12375
- file: cf.path,
12376
- lineRange: [i + 1, i + 1],
12377
- domain: "security",
12378
- severity: "critical",
12379
- title: `Dangerous ${"eval"}() or new ${"Function"}() usage`,
12380
- rationale: `${"eval"}() and new ${"Function"}() execute arbitrary code. If user input reaches these calls, it enables Remote Code Execution (CWE-94).`,
12381
- suggestion: "Replace eval/Function with a safe alternative (JSON.parse for data, a sandboxed evaluator for expressions).",
12382
- evidence: [`Line ${i + 1}: ${line.trim()}`],
12383
- validatedBy: "heuristic",
12384
- cweId: "CWE-94",
12385
- owaspCategory: "A03:2021 Injection",
12386
- confidence: "high",
12387
- remediation: "Replace eval/Function with a safe alternative (JSON.parse for data, a sandboxed evaluator for expressions).",
12388
- references: [
12389
- "https://cwe.mitre.org/data/definitions/94.html",
12390
- "https://owasp.org/Top10/A03_2021-Injection/"
12391
- ]
12392
- });
12393
- }
12604
+ if (!EVAL_PATTERN.test(line)) continue;
12605
+ findings.push(makeEvalFinding(cf.path, i + 1, line));
12394
12606
  }
12395
12607
  }
12396
12608
  return findings;
@@ -12402,31 +12614,9 @@ function detectHardcodedSecrets(bundle) {
12402
12614
  for (let i = 0; i < lines.length; i++) {
12403
12615
  const line = lines[i];
12404
12616
  const codePart = line.includes("//") ? line.slice(0, line.indexOf("//")) : line;
12405
- for (const pattern of SECRET_PATTERNS) {
12406
- if (pattern.test(codePart)) {
12407
- findings.push({
12408
- id: makeFindingId("security", cf.path, i + 1, "hardcoded secret CWE-798"),
12409
- file: cf.path,
12410
- lineRange: [i + 1, i + 1],
12411
- domain: "security",
12412
- severity: "critical",
12413
- title: "Hardcoded secret or API key detected",
12414
- rationale: "Hardcoded secrets in source code can be extracted from version history even after removal. Use environment variables or a secrets manager (CWE-798).",
12415
- suggestion: "Move the secret to an environment variable and access it via process.env.",
12416
- evidence: [`Line ${i + 1}: [secret detected \u2014 value redacted]`],
12417
- validatedBy: "heuristic",
12418
- cweId: "CWE-798",
12419
- owaspCategory: "A07:2021 Identification and Authentication Failures",
12420
- confidence: "high",
12421
- remediation: "Move the secret to an environment variable and access it via process.env.",
12422
- references: [
12423
- "https://cwe.mitre.org/data/definitions/798.html",
12424
- "https://owasp.org/Top10/A07_2021-Identification_and_Authentication_Failures/"
12425
- ]
12426
- });
12427
- break;
12428
- }
12429
- }
12617
+ const matched = SECRET_PATTERNS.some((p) => p.test(codePart));
12618
+ if (!matched) continue;
12619
+ findings.push(makeSecretFinding(cf.path, i + 1));
12430
12620
  }
12431
12621
  }
12432
12622
  return findings;
@@ -12437,28 +12627,8 @@ function detectSqlInjection(bundle) {
12437
12627
  const lines = cf.content.split("\n");
12438
12628
  for (let i = 0; i < lines.length; i++) {
12439
12629
  const line = lines[i];
12440
- if (SQL_CONCAT_PATTERN.test(line)) {
12441
- findings.push({
12442
- id: makeFindingId("security", cf.path, i + 1, "SQL injection CWE-89"),
12443
- file: cf.path,
12444
- lineRange: [i + 1, i + 1],
12445
- domain: "security",
12446
- severity: "critical",
12447
- title: "Potential SQL injection via string concatenation",
12448
- rationale: "Building SQL queries with string concatenation or template literals allows attackers to inject malicious SQL (CWE-89).",
12449
- suggestion: "Use parameterized queries or a query builder (e.g., Knex, Prisma) instead of string concatenation.",
12450
- evidence: [`Line ${i + 1}: ${line.trim()}`],
12451
- validatedBy: "heuristic",
12452
- cweId: "CWE-89",
12453
- owaspCategory: "A03:2021 Injection",
12454
- confidence: "high",
12455
- remediation: "Use parameterized queries or a query builder (e.g., Knex, Prisma) instead of string concatenation.",
12456
- references: [
12457
- "https://cwe.mitre.org/data/definitions/89.html",
12458
- "https://owasp.org/Top10/A03_2021-Injection/"
12459
- ]
12460
- });
12461
- }
12630
+ if (!SQL_CONCAT_PATTERN.test(line)) continue;
12631
+ findings.push(makeSqlFinding(cf.path, i + 1, line));
12462
12632
  }
12463
12633
  }
12464
12634
  return findings;
@@ -12469,28 +12639,8 @@ function detectCommandInjection(bundle) {
12469
12639
  const lines = cf.content.split("\n");
12470
12640
  for (let i = 0; i < lines.length; i++) {
12471
12641
  const line = lines[i];
12472
- if (SHELL_EXEC_PATTERN.test(line)) {
12473
- findings.push({
12474
- id: makeFindingId("security", cf.path, i + 1, "command injection CWE-78"),
12475
- file: cf.path,
12476
- lineRange: [i + 1, i + 1],
12477
- domain: "security",
12478
- severity: "critical",
12479
- title: "Potential command injection via shell exec with interpolation",
12480
- rationale: "Using exec/spawn with template literal interpolation allows attackers to inject shell commands (CWE-78).",
12481
- suggestion: "Use execFile or spawn with an arguments array instead of shell string interpolation.",
12482
- evidence: [`Line ${i + 1}: ${line.trim()}`],
12483
- validatedBy: "heuristic",
12484
- cweId: "CWE-78",
12485
- owaspCategory: "A03:2021 Injection",
12486
- confidence: "high",
12487
- remediation: "Use execFile or spawn with an arguments array instead of shell string interpolation.",
12488
- references: [
12489
- "https://cwe.mitre.org/data/definitions/78.html",
12490
- "https://owasp.org/Top10/A03_2021-Injection/"
12491
- ]
12492
- });
12493
- }
12642
+ if (!SHELL_EXEC_PATTERN.test(line)) continue;
12643
+ findings.push(makeCommandFinding(cf.path, i + 1, line));
12494
12644
  }
12495
12645
  }
12496
12646
  return findings;
@@ -12523,10 +12673,15 @@ function isViolationLine(line) {
12523
12673
  const lower = line.toLowerCase();
12524
12674
  return lower.includes("violation") || lower.includes("layer");
12525
12675
  }
12526
- function createLayerViolationFinding(line, fallbackPath) {
12527
- const fileMatch = line.match(/(?:in\s+)?(\S+\.(?:ts|tsx|js|jsx))(?::(\d+))?/);
12676
+ var VIOLATION_FILE_RE = /(?:in\s+)?(\S+\.(?:ts|tsx|js|jsx))(?::(\d+))?/;
12677
+ function extractViolationLocation(line, fallbackPath) {
12678
+ const fileMatch = line.match(VIOLATION_FILE_RE);
12528
12679
  const file = fileMatch?.[1] ?? fallbackPath;
12529
12680
  const lineNum = fileMatch?.[2] ? parseInt(fileMatch[2], 10) : 1;
12681
+ return { file, lineNum };
12682
+ }
12683
+ function createLayerViolationFinding(line, fallbackPath) {
12684
+ const { file, lineNum } = extractViolationLocation(line, fallbackPath);
12530
12685
  return {
12531
12686
  id: makeFindingId("arch", file, lineNum, "layer violation"),
12532
12687
  file,
@@ -12699,6 +12854,26 @@ function normalizePath(filePath, projectRoot) {
12699
12854
  }
12700
12855
  return normalized;
12701
12856
  }
12857
+ function resolveImportPath3(currentFile, importPath) {
12858
+ const dir = path23.dirname(currentFile);
12859
+ let resolved = path23.join(dir, importPath).replace(/\\/g, "/");
12860
+ if (!resolved.match(/\.(ts|tsx|js|jsx)$/)) {
12861
+ resolved += ".ts";
12862
+ }
12863
+ return path23.normalize(resolved).replace(/\\/g, "/");
12864
+ }
12865
+ function enqueueImports(content, current, visited, queue, maxDepth) {
12866
+ const importRegex = /import\s+.*?from\s+['"]([^'"]+)['"]/g;
12867
+ let match;
12868
+ while ((match = importRegex.exec(content)) !== null) {
12869
+ const importPath = match[1];
12870
+ if (!importPath.startsWith(".")) continue;
12871
+ const resolved = resolveImportPath3(current.file, importPath);
12872
+ if (!visited.has(resolved) && current.depth + 1 <= maxDepth) {
12873
+ queue.push({ file: resolved, depth: current.depth + 1 });
12874
+ }
12875
+ }
12876
+ }
12702
12877
  function followImportChain(fromFile, fileContents, maxDepth = 2) {
12703
12878
  const visited = /* @__PURE__ */ new Set();
12704
12879
  const queue = [{ file: fromFile, depth: 0 }];
@@ -12708,82 +12883,63 @@ function followImportChain(fromFile, fileContents, maxDepth = 2) {
12708
12883
  visited.add(current.file);
12709
12884
  const content = fileContents.get(current.file);
12710
12885
  if (!content) continue;
12711
- const importRegex = /import\s+.*?from\s+['"]([^'"]+)['"]/g;
12712
- let match;
12713
- while ((match = importRegex.exec(content)) !== null) {
12714
- const importPath = match[1];
12715
- if (!importPath.startsWith(".")) continue;
12716
- const dir = path23.dirname(current.file);
12717
- let resolved = path23.join(dir, importPath).replace(/\\/g, "/");
12718
- if (!resolved.match(/\.(ts|tsx|js|jsx)$/)) {
12719
- resolved += ".ts";
12720
- }
12721
- resolved = path23.normalize(resolved).replace(/\\/g, "/");
12722
- if (!visited.has(resolved) && current.depth + 1 <= maxDepth) {
12723
- queue.push({ file: resolved, depth: current.depth + 1 });
12724
- }
12725
- }
12886
+ enqueueImports(content, current, visited, queue, maxDepth);
12726
12887
  }
12727
12888
  visited.delete(fromFile);
12728
12889
  return visited;
12729
12890
  }
12891
+ function isMechanicallyExcluded(finding, exclusionSet, projectRoot) {
12892
+ const normalizedFile = normalizePath(finding.file, projectRoot);
12893
+ if (exclusionSet.isExcluded(normalizedFile, finding.lineRange)) return true;
12894
+ if (exclusionSet.isExcluded(finding.file, finding.lineRange)) return true;
12895
+ const absoluteFile = path23.isAbsolute(finding.file) ? finding.file : path23.join(projectRoot, finding.file).replace(/\\/g, "/");
12896
+ return exclusionSet.isExcluded(absoluteFile, finding.lineRange);
12897
+ }
12898
+ async function validateWithGraph(crossFileRefs, graph) {
12899
+ try {
12900
+ for (const ref of crossFileRefs) {
12901
+ const reachable = await graph.isReachable(ref.from, ref.to);
12902
+ if (!reachable) return { result: "discard" };
12903
+ }
12904
+ return { result: "keep" };
12905
+ } catch {
12906
+ return { result: "fallback" };
12907
+ }
12908
+ }
12909
+ function validateWithHeuristic(finding, crossFileRefs, fileContents, projectRoot) {
12910
+ if (fileContents) {
12911
+ for (const ref of crossFileRefs) {
12912
+ const normalizedFrom = normalizePath(ref.from, projectRoot);
12913
+ const reachable = followImportChain(normalizedFrom, fileContents, 2);
12914
+ const normalizedTo = normalizePath(ref.to, projectRoot);
12915
+ if (reachable.has(normalizedTo)) {
12916
+ return { ...finding, validatedBy: "heuristic" };
12917
+ }
12918
+ }
12919
+ }
12920
+ return {
12921
+ ...finding,
12922
+ severity: DOWNGRADE_MAP[finding.severity],
12923
+ validatedBy: "heuristic"
12924
+ };
12925
+ }
12926
+ async function processFinding(finding, exclusionSet, graph, projectRoot, fileContents) {
12927
+ if (isMechanicallyExcluded(finding, exclusionSet, projectRoot)) return null;
12928
+ const crossFileRefs = extractCrossFileRefs(finding);
12929
+ if (crossFileRefs.length === 0) return { ...finding };
12930
+ if (graph) {
12931
+ const { result } = await validateWithGraph(crossFileRefs, graph);
12932
+ if (result === "keep") return { ...finding, validatedBy: "graph" };
12933
+ if (result === "discard") return null;
12934
+ }
12935
+ return validateWithHeuristic(finding, crossFileRefs, fileContents, projectRoot);
12936
+ }
12730
12937
  async function validateFindings(options) {
12731
12938
  const { findings, exclusionSet, graph, projectRoot, fileContents } = options;
12732
12939
  const validated = [];
12733
12940
  for (const finding of findings) {
12734
- const normalizedFile = normalizePath(finding.file, projectRoot);
12735
- if (exclusionSet.isExcluded(normalizedFile, finding.lineRange) || exclusionSet.isExcluded(finding.file, finding.lineRange)) {
12736
- continue;
12737
- }
12738
- const absoluteFile = path23.isAbsolute(finding.file) ? finding.file : path23.join(projectRoot, finding.file).replace(/\\/g, "/");
12739
- if (exclusionSet.isExcluded(absoluteFile, finding.lineRange)) {
12740
- continue;
12741
- }
12742
- const crossFileRefs = extractCrossFileRefs(finding);
12743
- if (crossFileRefs.length === 0) {
12744
- validated.push({ ...finding });
12745
- continue;
12746
- }
12747
- if (graph) {
12748
- try {
12749
- let allReachable = true;
12750
- for (const ref of crossFileRefs) {
12751
- const reachable = await graph.isReachable(ref.from, ref.to);
12752
- if (!reachable) {
12753
- allReachable = false;
12754
- break;
12755
- }
12756
- }
12757
- if (allReachable) {
12758
- validated.push({ ...finding, validatedBy: "graph" });
12759
- }
12760
- continue;
12761
- } catch {
12762
- }
12763
- }
12764
- {
12765
- let chainValidated = false;
12766
- if (fileContents) {
12767
- for (const ref of crossFileRefs) {
12768
- const normalizedFrom = normalizePath(ref.from, projectRoot);
12769
- const reachable = followImportChain(normalizedFrom, fileContents, 2);
12770
- const normalizedTo = normalizePath(ref.to, projectRoot);
12771
- if (reachable.has(normalizedTo)) {
12772
- chainValidated = true;
12773
- break;
12774
- }
12775
- }
12776
- }
12777
- if (chainValidated) {
12778
- validated.push({ ...finding, validatedBy: "heuristic" });
12779
- } else {
12780
- validated.push({
12781
- ...finding,
12782
- severity: DOWNGRADE_MAP[finding.severity],
12783
- validatedBy: "heuristic"
12784
- });
12785
- }
12786
- }
12941
+ const result = await processFinding(finding, exclusionSet, graph, projectRoot, fileContents);
12942
+ if (result !== null) validated.push(result);
12787
12943
  }
12788
12944
  return validated;
12789
12945
  }
@@ -13384,25 +13540,32 @@ function serializeRoadmap(roadmap) {
13384
13540
  function serializeMilestoneHeading(milestone) {
13385
13541
  return milestone.isBacklog ? "## Backlog" : `## ${milestone.name}`;
13386
13542
  }
13543
+ function orDash(value) {
13544
+ return value ?? EM_DASH2;
13545
+ }
13546
+ function listOrDash(items) {
13547
+ return items.length > 0 ? items.join(", ") : EM_DASH2;
13548
+ }
13549
+ function serializeExtendedLines(feature) {
13550
+ const hasExtended = feature.assignee !== null || feature.priority !== null || feature.externalId !== null;
13551
+ if (!hasExtended) return [];
13552
+ return [
13553
+ `- **Assignee:** ${orDash(feature.assignee)}`,
13554
+ `- **Priority:** ${orDash(feature.priority)}`,
13555
+ `- **External-ID:** ${orDash(feature.externalId)}`
13556
+ ];
13557
+ }
13387
13558
  function serializeFeature(feature) {
13388
- const spec = feature.spec ?? EM_DASH2;
13389
- const plans = feature.plans.length > 0 ? feature.plans.join(", ") : EM_DASH2;
13390
- const blockedBy = feature.blockedBy.length > 0 ? feature.blockedBy.join(", ") : EM_DASH2;
13391
13559
  const lines = [
13392
13560
  `### ${feature.name}`,
13393
13561
  "",
13394
13562
  `- **Status:** ${feature.status}`,
13395
- `- **Spec:** ${spec}`,
13563
+ `- **Spec:** ${orDash(feature.spec)}`,
13396
13564
  `- **Summary:** ${feature.summary}`,
13397
- `- **Blockers:** ${blockedBy}`,
13398
- `- **Plan:** ${plans}`
13565
+ `- **Blockers:** ${listOrDash(feature.blockedBy)}`,
13566
+ `- **Plan:** ${listOrDash(feature.plans)}`,
13567
+ ...serializeExtendedLines(feature)
13399
13568
  ];
13400
- const hasExtended = feature.assignee !== null || feature.priority !== null || feature.externalId !== null;
13401
- if (hasExtended) {
13402
- lines.push(`- **Assignee:** ${feature.assignee ?? EM_DASH2}`);
13403
- lines.push(`- **Priority:** ${feature.priority ?? EM_DASH2}`);
13404
- lines.push(`- **External-ID:** ${feature.externalId ?? EM_DASH2}`);
13405
- }
13406
13569
  return lines;
13407
13570
  }
13408
13571
  function serializeAssignmentHistory(records) {
@@ -13436,6 +13599,26 @@ function isRegression(from, to) {
13436
13599
  }
13437
13600
 
13438
13601
  // src/roadmap/sync.ts
13602
+ function collectAutopilotStatuses(autopilotPath, featurePlans, allTaskStatuses) {
13603
+ try {
13604
+ const raw = fs24.readFileSync(autopilotPath, "utf-8");
13605
+ const autopilot = JSON.parse(raw);
13606
+ if (!autopilot.phases) return;
13607
+ const linkedPhases = autopilot.phases.filter(
13608
+ (phase) => phase.planPath ? featurePlans.some((p) => p === phase.planPath || phase.planPath.endsWith(p)) : false
13609
+ );
13610
+ for (const phase of linkedPhases) {
13611
+ if (phase.status === "complete") {
13612
+ allTaskStatuses.push("complete");
13613
+ } else if (phase.status === "pending") {
13614
+ allTaskStatuses.push("pending");
13615
+ } else {
13616
+ allTaskStatuses.push("in_progress");
13617
+ }
13618
+ }
13619
+ } catch {
13620
+ }
13621
+ }
13439
13622
  function inferStatus(feature, projectPath, allFeatures) {
13440
13623
  if (feature.blockedBy.length > 0) {
13441
13624
  const blockerNotDone = feature.blockedBy.some((blockerName) => {
@@ -13471,26 +13654,7 @@ function inferStatus(feature, projectPath, allFeatures) {
13471
13654
  if (!entry.isDirectory()) continue;
13472
13655
  const autopilotPath = path24.join(sessionsDir, entry.name, "autopilot-state.json");
13473
13656
  if (!fs24.existsSync(autopilotPath)) continue;
13474
- try {
13475
- const raw = fs24.readFileSync(autopilotPath, "utf-8");
13476
- const autopilot = JSON.parse(raw);
13477
- if (!autopilot.phases) continue;
13478
- const linkedPhases = autopilot.phases.filter(
13479
- (phase) => phase.planPath ? feature.plans.some((p) => p === phase.planPath || phase.planPath.endsWith(p)) : false
13480
- );
13481
- if (linkedPhases.length > 0) {
13482
- for (const phase of linkedPhases) {
13483
- if (phase.status === "complete") {
13484
- allTaskStatuses.push("complete");
13485
- } else if (phase.status === "pending") {
13486
- allTaskStatuses.push("pending");
13487
- } else {
13488
- allTaskStatuses.push("in_progress");
13489
- }
13490
- }
13491
- }
13492
- } catch {
13493
- }
13657
+ collectAutopilotStatuses(autopilotPath, feature.plans, allTaskStatuses);
13494
13658
  }
13495
13659
  } catch {
13496
13660
  }
@@ -13807,23 +13971,36 @@ var GitHubIssuesSyncAdapter = class {
13807
13971
  return (0, import_types25.Err)(error instanceof Error ? error : new Error(String(error)));
13808
13972
  }
13809
13973
  }
13974
+ buildLabelsParam() {
13975
+ const filterLabels = this.config.labels ?? [];
13976
+ return filterLabels.length > 0 ? `&labels=${filterLabels.join(",")}` : "";
13977
+ }
13978
+ issueToTicketState(issue) {
13979
+ return {
13980
+ externalId: buildExternalId(this.owner, this.repo, issue.number),
13981
+ title: issue.title,
13982
+ status: issue.state,
13983
+ labels: issue.labels.map((l) => l.name),
13984
+ assignee: issue.assignee ? `@${issue.assignee.login}` : null
13985
+ };
13986
+ }
13987
+ async fetchIssuePage(page, labelsParam) {
13988
+ const perPage = 100;
13989
+ return fetchWithRetry(
13990
+ this.fetchFn,
13991
+ `${this.apiBase}/repos/${this.owner}/${this.repo}/issues?state=all&per_page=${perPage}&page=${page}${labelsParam}`,
13992
+ { method: "GET", headers: this.headers() },
13993
+ this.retryOpts
13994
+ );
13995
+ }
13810
13996
  async fetchAllTickets() {
13811
13997
  try {
13812
- const filterLabels = this.config.labels ?? [];
13813
- const labelsParam = filterLabels.length > 0 ? `&labels=${filterLabels.join(",")}` : "";
13998
+ const labelsParam = this.buildLabelsParam();
13814
13999
  const tickets = [];
13815
14000
  let page = 1;
13816
14001
  const perPage = 100;
13817
14002
  while (true) {
13818
- const response = await fetchWithRetry(
13819
- this.fetchFn,
13820
- `${this.apiBase}/repos/${this.owner}/${this.repo}/issues?state=all&per_page=${perPage}&page=${page}${labelsParam}`,
13821
- {
13822
- method: "GET",
13823
- headers: this.headers()
13824
- },
13825
- this.retryOpts
13826
- );
14003
+ const response = await this.fetchIssuePage(page, labelsParam);
13827
14004
  if (!response.ok) {
13828
14005
  const text = await response.text();
13829
14006
  return (0, import_types25.Err)(new Error(`GitHub API error ${response.status}: ${text}`));
@@ -13831,13 +14008,7 @@ var GitHubIssuesSyncAdapter = class {
13831
14008
  const data = await response.json();
13832
14009
  const issues = data.filter((d) => !d.pull_request);
13833
14010
  for (const issue of issues) {
13834
- tickets.push({
13835
- externalId: buildExternalId(this.owner, this.repo, issue.number),
13836
- title: issue.title,
13837
- status: issue.state,
13838
- labels: issue.labels.map((l) => l.name),
13839
- assignee: issue.assignee ? `@${issue.assignee.login}` : null
13840
- });
14011
+ tickets.push(this.issueToTicketState(issue));
13841
14012
  }
13842
14013
  if (data.length < perPage) break;
13843
14014
  page++;
@@ -13932,6 +14103,22 @@ async function syncToExternal(roadmap, adapter, config, prefetchedTickets) {
13932
14103
  }
13933
14104
  return result;
13934
14105
  }
14106
+ function applyTicketToFeature(ticketState, feature, config, forceSync, result) {
14107
+ if (ticketState.assignee !== feature.assignee) {
14108
+ result.assignmentChanges.push({
14109
+ feature: feature.name,
14110
+ from: feature.assignee,
14111
+ to: ticketState.assignee
14112
+ });
14113
+ feature.assignee = ticketState.assignee;
14114
+ }
14115
+ const resolvedStatus = resolveReverseStatus(ticketState.status, ticketState.labels, config);
14116
+ if (!resolvedStatus || resolvedStatus === feature.status) return;
14117
+ const newStatus = resolvedStatus;
14118
+ if (!forceSync && isRegression(feature.status, newStatus)) return;
14119
+ if (!forceSync && feature.status === "blocked" && newStatus === "planned") return;
14120
+ feature.status = newStatus;
14121
+ }
13935
14122
  async function syncFromExternal(roadmap, adapter, config, options, prefetchedTickets) {
13936
14123
  const result = emptySyncResult();
13937
14124
  const forceSync = options?.forceSync ?? false;
@@ -13958,25 +14145,7 @@ async function syncFromExternal(roadmap, adapter, config, options, prefetchedTic
13958
14145
  for (const ticketState of tickets) {
13959
14146
  const feature = featureByExternalId.get(ticketState.externalId);
13960
14147
  if (!feature) continue;
13961
- if (ticketState.assignee !== feature.assignee) {
13962
- result.assignmentChanges.push({
13963
- feature: feature.name,
13964
- from: feature.assignee,
13965
- to: ticketState.assignee
13966
- });
13967
- feature.assignee = ticketState.assignee;
13968
- }
13969
- const resolvedStatus = resolveReverseStatus(ticketState.status, ticketState.labels, config);
13970
- if (resolvedStatus && resolvedStatus !== feature.status) {
13971
- const newStatus = resolvedStatus;
13972
- if (!forceSync && isRegression(feature.status, newStatus)) {
13973
- continue;
13974
- }
13975
- if (!forceSync && feature.status === "blocked" && newStatus === "planned") {
13976
- continue;
13977
- }
13978
- feature.status = newStatus;
13979
- }
14148
+ applyTicketToFeature(ticketState, feature, config, forceSync, result);
13980
14149
  }
13981
14150
  return result;
13982
14151
  }
@@ -14024,6 +14193,24 @@ var PRIORITY_RANK = {
14024
14193
  var POSITION_WEIGHT = 0.5;
14025
14194
  var DEPENDENTS_WEIGHT = 0.3;
14026
14195
  var AFFINITY_WEIGHT = 0.2;
14196
+ function isEligibleCandidate(feature, allFeatureNames, doneFeatures) {
14197
+ if (feature.status !== "planned" && feature.status !== "backlog") return false;
14198
+ const isBlocked = feature.blockedBy.some((blocker) => {
14199
+ const key = blocker.toLowerCase();
14200
+ return allFeatureNames.has(key) && !doneFeatures.has(key);
14201
+ });
14202
+ return !isBlocked;
14203
+ }
14204
+ function computeAffinityScore(feature, milestoneName, milestoneMap, userCompletedFeatures) {
14205
+ if (userCompletedFeatures.size === 0) return 0;
14206
+ const completedBlocker = feature.blockedBy.some(
14207
+ (b) => userCompletedFeatures.has(b.toLowerCase())
14208
+ );
14209
+ if (completedBlocker) return 1;
14210
+ const siblings = milestoneMap.get(milestoneName) ?? [];
14211
+ const completedSibling = siblings.some((s) => userCompletedFeatures.has(s));
14212
+ return completedSibling ? 0.5 : 0;
14213
+ }
14027
14214
  function scoreRoadmapCandidates(roadmap, options) {
14028
14215
  const allFeatures = roadmap.milestones.flatMap((m) => m.features);
14029
14216
  const allFeatureNames = new Set(allFeatures.map((f) => f.name.toLowerCase()));
@@ -14062,33 +14249,18 @@ function scoreRoadmapCandidates(roadmap, options) {
14062
14249
  const candidates = [];
14063
14250
  let globalPosition = 0;
14064
14251
  for (const ms of roadmap.milestones) {
14065
- for (let featureIdx = 0; featureIdx < ms.features.length; featureIdx++) {
14066
- const feature = ms.features[featureIdx];
14252
+ for (const feature of ms.features) {
14067
14253
  globalPosition++;
14068
- if (feature.status !== "planned" && feature.status !== "backlog") continue;
14069
- const isBlocked = feature.blockedBy.some((blocker) => {
14070
- const key = blocker.toLowerCase();
14071
- return allFeatureNames.has(key) && !doneFeatures.has(key);
14072
- });
14073
- if (isBlocked) continue;
14254
+ if (!isEligibleCandidate(feature, allFeatureNames, doneFeatures)) continue;
14074
14255
  const positionScore = 1 - (globalPosition - 1) / totalPositions;
14075
14256
  const deps = dependentsCount.get(feature.name.toLowerCase()) ?? 0;
14076
14257
  const dependentsScore = deps / maxDependents;
14077
- let affinityScore = 0;
14078
- if (userCompletedFeatures.size > 0) {
14079
- const completedBlockers = feature.blockedBy.filter(
14080
- (b) => userCompletedFeatures.has(b.toLowerCase())
14081
- );
14082
- if (completedBlockers.length > 0) {
14083
- affinityScore = 1;
14084
- } else {
14085
- const siblings = milestoneMap.get(ms.name) ?? [];
14086
- const completedSiblings = siblings.filter((s) => userCompletedFeatures.has(s));
14087
- if (completedSiblings.length > 0) {
14088
- affinityScore = 0.5;
14089
- }
14090
- }
14091
- }
14258
+ const affinityScore = computeAffinityScore(
14259
+ feature,
14260
+ ms.name,
14261
+ milestoneMap,
14262
+ userCompletedFeatures
14263
+ );
14092
14264
  const weightedScore = POSITION_WEIGHT * positionScore + DEPENDENTS_WEIGHT * dependentsScore + AFFINITY_WEIGHT * affinityScore;
14093
14265
  const priorityTier = feature.priority ? PRIORITY_RANK[feature.priority] : null;
14094
14266
  candidates.push({
@@ -15150,6 +15322,27 @@ function aggregateByDay(records) {
15150
15322
  // src/usage/jsonl-reader.ts
15151
15323
  var fs30 = __toESM(require("fs"));
15152
15324
  var path29 = __toESM(require("path"));
15325
+ function extractTokenUsage(entry, lineNumber) {
15326
+ const tokenUsage = entry.token_usage;
15327
+ if (!tokenUsage || typeof tokenUsage !== "object") {
15328
+ console.warn(
15329
+ `[harness usage] Skipping malformed JSONL line ${lineNumber}: missing token_usage`
15330
+ );
15331
+ return null;
15332
+ }
15333
+ return tokenUsage;
15334
+ }
15335
+ function applyOptionalFields2(record, entry) {
15336
+ if (entry.cache_creation_tokens != null) {
15337
+ record.cacheCreationTokens = entry.cache_creation_tokens;
15338
+ }
15339
+ if (entry.cache_read_tokens != null) {
15340
+ record.cacheReadTokens = entry.cache_read_tokens;
15341
+ }
15342
+ if (entry.model != null) {
15343
+ record.model = entry.model;
15344
+ }
15345
+ }
15153
15346
  function parseLine(line, lineNumber) {
15154
15347
  let entry;
15155
15348
  try {
@@ -15158,13 +15351,8 @@ function parseLine(line, lineNumber) {
15158
15351
  console.warn(`[harness usage] Skipping malformed JSONL line ${lineNumber}`);
15159
15352
  return null;
15160
15353
  }
15161
- const tokenUsage = entry.token_usage;
15162
- if (!tokenUsage || typeof tokenUsage !== "object") {
15163
- console.warn(
15164
- `[harness usage] Skipping malformed JSONL line ${lineNumber}: missing token_usage`
15165
- );
15166
- return null;
15167
- }
15354
+ const tokenUsage = extractTokenUsage(entry, lineNumber);
15355
+ if (!tokenUsage) return null;
15168
15356
  const inputTokens = tokenUsage.input_tokens ?? 0;
15169
15357
  const outputTokens = tokenUsage.output_tokens ?? 0;
15170
15358
  const record = {
@@ -15176,15 +15364,7 @@ function parseLine(line, lineNumber) {
15176
15364
  totalTokens: inputTokens + outputTokens
15177
15365
  }
15178
15366
  };
15179
- if (entry.cache_creation_tokens != null) {
15180
- record.cacheCreationTokens = entry.cache_creation_tokens;
15181
- }
15182
- if (entry.cache_read_tokens != null) {
15183
- record.cacheReadTokens = entry.cache_read_tokens;
15184
- }
15185
- if (entry.model != null) {
15186
- record.model = entry.model;
15187
- }
15367
+ applyOptionalFields2(record, entry);
15188
15368
  return record;
15189
15369
  }
15190
15370
  function readCostRecords(projectRoot) {
@@ -15219,6 +15399,14 @@ function extractUsage(entry) {
15219
15399
  const usage = message.usage;
15220
15400
  return usage && typeof usage === "object" && !Array.isArray(usage) ? usage : null;
15221
15401
  }
15402
+ function applyOptionalCCFields(record, message, usage) {
15403
+ const model = message.model;
15404
+ if (model) record.model = model;
15405
+ const cacheCreate = usage.cache_creation_input_tokens;
15406
+ const cacheRead = usage.cache_read_input_tokens;
15407
+ if (typeof cacheCreate === "number" && cacheCreate > 0) record.cacheCreationTokens = cacheCreate;
15408
+ if (typeof cacheRead === "number" && cacheRead > 0) record.cacheReadTokens = cacheRead;
15409
+ }
15222
15410
  function buildRecord(entry, usage) {
15223
15411
  const inputTokens = Number(usage.input_tokens) || 0;
15224
15412
  const outputTokens = Number(usage.output_tokens) || 0;
@@ -15229,12 +15417,7 @@ function buildRecord(entry, usage) {
15229
15417
  tokens: { inputTokens, outputTokens, totalTokens: inputTokens + outputTokens },
15230
15418
  _source: "claude-code"
15231
15419
  };
15232
- const model = message.model;
15233
- if (model) record.model = model;
15234
- const cacheCreate = usage.cache_creation_input_tokens;
15235
- const cacheRead = usage.cache_read_input_tokens;
15236
- if (typeof cacheCreate === "number" && cacheCreate > 0) record.cacheCreationTokens = cacheCreate;
15237
- if (typeof cacheRead === "number" && cacheRead > 0) record.cacheReadTokens = cacheRead;
15420
+ applyOptionalCCFields(record, message, usage);
15238
15421
  return record;
15239
15422
  }
15240
15423
  function parseCCLine(line, filePath, lineNumber) {
@@ -15302,7 +15485,7 @@ function parseCCRecords() {
15302
15485
  }
15303
15486
 
15304
15487
  // src/index.ts
15305
- var VERSION = "0.15.0";
15488
+ var VERSION = "0.21.1";
15306
15489
  // Annotate the CommonJS export names for ESM import in node:
15307
15490
  0 && (module.exports = {
15308
15491
  AGENT_DESCRIPTORS,
@@ -15333,7 +15516,6 @@ var VERSION = "0.15.0";
15333
15516
  ConfirmationSchema,
15334
15517
  ConsoleSink,
15335
15518
  ConstraintRuleSchema,
15336
- ContentPipeline,
15337
15519
  ContributingFeatureSchema,
15338
15520
  ContributionsSchema,
15339
15521
  CouplingCollector,