@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/architecture/matchers.d.mts +1 -1
- package/dist/architecture/matchers.d.ts +1 -1
- package/dist/architecture/matchers.js +383 -332
- package/dist/architecture/matchers.mjs +1 -1
- package/dist/{chunk-BQUWXBGR.mjs → chunk-4W4FRAA6.mjs} +383 -332
- package/dist/index.d.mts +174 -181
- package/dist/index.d.ts +174 -181
- package/dist/index.js +1511 -1329
- package/dist/index.mjs +1122 -990
- package/dist/{matchers-D20x48U9.d.mts → matchers-Dj1t5vpg.d.mts} +46 -46
- package/dist/{matchers-D20x48U9.d.ts → matchers-Dj1t5vpg.d.ts} +46 -46
- package/package.json +3 -3
package/dist/index.mjs
CHANGED
|
@@ -41,7 +41,7 @@ import {
|
|
|
41
41
|
runAll,
|
|
42
42
|
validateDependencies,
|
|
43
43
|
violationId
|
|
44
|
-
} from "./chunk-
|
|
44
|
+
} from "./chunk-4W4FRAA6.mjs";
|
|
45
45
|
|
|
46
46
|
// src/index.ts
|
|
47
47
|
export * from "@harness-engineering/types";
|
|
@@ -149,21 +149,11 @@ function validateCommitMessage(message, format = "conventional") {
|
|
|
149
149
|
issues: []
|
|
150
150
|
});
|
|
151
151
|
}
|
|
152
|
-
function
|
|
153
|
-
const lines = message.split("\n");
|
|
154
|
-
const headerLine = lines[0];
|
|
155
|
-
if (!headerLine) {
|
|
156
|
-
const error = createError(
|
|
157
|
-
"VALIDATION_FAILED",
|
|
158
|
-
"Commit message header cannot be empty",
|
|
159
|
-
{ message },
|
|
160
|
-
["Provide a commit message with at least a header line"]
|
|
161
|
-
);
|
|
162
|
-
return Err(error);
|
|
163
|
-
}
|
|
152
|
+
function parseConventionalHeader(message, headerLine) {
|
|
164
153
|
const match = headerLine.match(CONVENTIONAL_PATTERN);
|
|
165
|
-
if (
|
|
166
|
-
|
|
154
|
+
if (match) return Ok(match);
|
|
155
|
+
return Err(
|
|
156
|
+
createError(
|
|
167
157
|
"VALIDATION_FAILED",
|
|
168
158
|
"Commit message does not follow conventional format",
|
|
169
159
|
{ message, header: headerLine },
|
|
@@ -172,13 +162,10 @@ function validateConventionalCommit(message) {
|
|
|
172
162
|
"Valid types: " + VALID_TYPES.join(", "),
|
|
173
163
|
"Example: feat(core): add new feature"
|
|
174
164
|
]
|
|
175
|
-
)
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
const scope = match[3];
|
|
180
|
-
const breaking = match[4] === "!";
|
|
181
|
-
const description = match[5];
|
|
165
|
+
)
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
function collectCommitIssues(type, description) {
|
|
182
169
|
const issues = [];
|
|
183
170
|
if (!VALID_TYPES.includes(type)) {
|
|
184
171
|
issues.push(`Invalid commit type "${type}". Valid types: ${VALID_TYPES.join(", ")}`);
|
|
@@ -186,34 +173,50 @@ function validateConventionalCommit(message) {
|
|
|
186
173
|
if (!description || description.trim() === "") {
|
|
187
174
|
issues.push("Commit description cannot be empty");
|
|
188
175
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
176
|
+
return issues;
|
|
177
|
+
}
|
|
178
|
+
function hasBreakingChangeInBody(lines) {
|
|
179
|
+
if (lines.length <= 1) return false;
|
|
180
|
+
return lines.slice(1).join("\n").includes("BREAKING CHANGE:");
|
|
181
|
+
}
|
|
182
|
+
function validateConventionalCommit(message) {
|
|
183
|
+
const lines = message.split("\n");
|
|
184
|
+
const headerLine = lines[0];
|
|
185
|
+
if (!headerLine) {
|
|
186
|
+
return Err(
|
|
187
|
+
createError(
|
|
188
|
+
"VALIDATION_FAILED",
|
|
189
|
+
"Commit message header cannot be empty",
|
|
190
|
+
{ message },
|
|
191
|
+
["Provide a commit message with at least a header line"]
|
|
192
|
+
)
|
|
193
|
+
);
|
|
195
194
|
}
|
|
195
|
+
const matchResult = parseConventionalHeader(message, headerLine);
|
|
196
|
+
if (!matchResult.ok) return matchResult;
|
|
197
|
+
const match = matchResult.value;
|
|
198
|
+
const type = match[1];
|
|
199
|
+
const scope = match[3];
|
|
200
|
+
const breaking = match[4] === "!";
|
|
201
|
+
const description = match[5];
|
|
202
|
+
const issues = collectCommitIssues(type, description);
|
|
196
203
|
if (issues.length > 0) {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
{ message, issues, type, scope },
|
|
205
|
-
["Review and fix the validation issues above"]
|
|
204
|
+
return Err(
|
|
205
|
+
createError(
|
|
206
|
+
"VALIDATION_FAILED",
|
|
207
|
+
`Commit message validation failed: ${issues.join("; ")}`,
|
|
208
|
+
{ message, issues, type, scope },
|
|
209
|
+
["Review and fix the validation issues above"]
|
|
210
|
+
)
|
|
206
211
|
);
|
|
207
|
-
return Err(error);
|
|
208
212
|
}
|
|
209
|
-
|
|
213
|
+
return Ok({
|
|
210
214
|
valid: true,
|
|
211
215
|
type,
|
|
212
216
|
...scope && { scope },
|
|
213
|
-
breaking:
|
|
217
|
+
breaking: breaking || hasBreakingChangeInBody(lines),
|
|
214
218
|
issues: []
|
|
215
|
-
};
|
|
216
|
-
return Ok(result);
|
|
219
|
+
});
|
|
217
220
|
}
|
|
218
221
|
|
|
219
222
|
// src/context/types.ts
|
|
@@ -671,6 +674,47 @@ var NODE_TYPE_TO_CATEGORY = {
|
|
|
671
674
|
prompt: "systemPrompt",
|
|
672
675
|
system: "systemPrompt"
|
|
673
676
|
};
|
|
677
|
+
function makeZeroWeights() {
|
|
678
|
+
return {
|
|
679
|
+
systemPrompt: 0,
|
|
680
|
+
projectManifest: 0,
|
|
681
|
+
taskSpec: 0,
|
|
682
|
+
activeCode: 0,
|
|
683
|
+
interfaces: 0,
|
|
684
|
+
reserve: 0
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
function normalizeRatios(ratios) {
|
|
688
|
+
const sum = Object.values(ratios).reduce((s, r) => s + r, 0);
|
|
689
|
+
if (sum === 0) return;
|
|
690
|
+
for (const key of Object.keys(ratios)) {
|
|
691
|
+
ratios[key] = ratios[key] / sum;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
function enforceMinimumRatios(ratios, min) {
|
|
695
|
+
for (const key of Object.keys(ratios)) {
|
|
696
|
+
if (ratios[key] < min) ratios[key] = min;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
function applyGraphDensity(ratios, graphDensity) {
|
|
700
|
+
const weights = makeZeroWeights();
|
|
701
|
+
for (const [nodeType, count] of Object.entries(graphDensity)) {
|
|
702
|
+
const category = NODE_TYPE_TO_CATEGORY[nodeType];
|
|
703
|
+
if (category) weights[category] += count;
|
|
704
|
+
}
|
|
705
|
+
const totalWeight = Object.values(weights).reduce((s, w) => s + w, 0);
|
|
706
|
+
if (totalWeight === 0) return;
|
|
707
|
+
const MIN = 0.01;
|
|
708
|
+
for (const key of Object.keys(ratios)) {
|
|
709
|
+
ratios[key] = weights[key] > 0 ? weights[key] / totalWeight : MIN;
|
|
710
|
+
}
|
|
711
|
+
if (ratios.reserve < DEFAULT_RATIOS.reserve) ratios.reserve = DEFAULT_RATIOS.reserve;
|
|
712
|
+
if (ratios.systemPrompt < DEFAULT_RATIOS.systemPrompt)
|
|
713
|
+
ratios.systemPrompt = DEFAULT_RATIOS.systemPrompt;
|
|
714
|
+
normalizeRatios(ratios);
|
|
715
|
+
enforceMinimumRatios(ratios, MIN);
|
|
716
|
+
normalizeRatios(ratios);
|
|
717
|
+
}
|
|
674
718
|
function contextBudget(totalTokens, overrides, graphDensity) {
|
|
675
719
|
const ratios = {
|
|
676
720
|
systemPrompt: DEFAULT_RATIOS.systemPrompt,
|
|
@@ -681,50 +725,7 @@ function contextBudget(totalTokens, overrides, graphDensity) {
|
|
|
681
725
|
reserve: DEFAULT_RATIOS.reserve
|
|
682
726
|
};
|
|
683
727
|
if (graphDensity) {
|
|
684
|
-
|
|
685
|
-
systemPrompt: 0,
|
|
686
|
-
projectManifest: 0,
|
|
687
|
-
taskSpec: 0,
|
|
688
|
-
activeCode: 0,
|
|
689
|
-
interfaces: 0,
|
|
690
|
-
reserve: 0
|
|
691
|
-
};
|
|
692
|
-
for (const [nodeType, count] of Object.entries(graphDensity)) {
|
|
693
|
-
const category = NODE_TYPE_TO_CATEGORY[nodeType];
|
|
694
|
-
if (category) {
|
|
695
|
-
categoryWeights[category] += count;
|
|
696
|
-
}
|
|
697
|
-
}
|
|
698
|
-
const totalWeight = Object.values(categoryWeights).reduce((sum, w) => sum + w, 0);
|
|
699
|
-
if (totalWeight > 0) {
|
|
700
|
-
const MIN_ALLOCATION = 0.01;
|
|
701
|
-
for (const key of Object.keys(ratios)) {
|
|
702
|
-
if (categoryWeights[key] > 0) {
|
|
703
|
-
ratios[key] = categoryWeights[key] / totalWeight;
|
|
704
|
-
} else {
|
|
705
|
-
ratios[key] = MIN_ALLOCATION;
|
|
706
|
-
}
|
|
707
|
-
}
|
|
708
|
-
if (ratios.reserve < DEFAULT_RATIOS.reserve) {
|
|
709
|
-
ratios.reserve = DEFAULT_RATIOS.reserve;
|
|
710
|
-
}
|
|
711
|
-
if (ratios.systemPrompt < DEFAULT_RATIOS.systemPrompt) {
|
|
712
|
-
ratios.systemPrompt = DEFAULT_RATIOS.systemPrompt;
|
|
713
|
-
}
|
|
714
|
-
const ratioSum = Object.values(ratios).reduce((sum, r) => sum + r, 0);
|
|
715
|
-
for (const key of Object.keys(ratios)) {
|
|
716
|
-
ratios[key] = ratios[key] / ratioSum;
|
|
717
|
-
}
|
|
718
|
-
for (const key of Object.keys(ratios)) {
|
|
719
|
-
if (ratios[key] < MIN_ALLOCATION) {
|
|
720
|
-
ratios[key] = MIN_ALLOCATION;
|
|
721
|
-
}
|
|
722
|
-
}
|
|
723
|
-
const finalSum = Object.values(ratios).reduce((sum, r) => sum + r, 0);
|
|
724
|
-
for (const key of Object.keys(ratios)) {
|
|
725
|
-
ratios[key] = ratios[key] / finalSum;
|
|
726
|
-
}
|
|
727
|
-
}
|
|
728
|
+
applyGraphDensity(ratios, graphDensity);
|
|
728
729
|
}
|
|
729
730
|
if (overrides) {
|
|
730
731
|
let overrideSum = 0;
|
|
@@ -1031,21 +1032,23 @@ function extractBundle(manifest, config) {
|
|
|
1031
1032
|
}
|
|
1032
1033
|
|
|
1033
1034
|
// src/constraints/sharing/merge.ts
|
|
1035
|
+
function arraysEqual(a, b) {
|
|
1036
|
+
if (a.length !== b.length) return false;
|
|
1037
|
+
return a.every((val, i) => deepEqual(val, b[i]));
|
|
1038
|
+
}
|
|
1039
|
+
function objectsEqual(a, b) {
|
|
1040
|
+
const keysA = Object.keys(a);
|
|
1041
|
+
const keysB = Object.keys(b);
|
|
1042
|
+
if (keysA.length !== keysB.length) return false;
|
|
1043
|
+
return keysA.every((key) => deepEqual(a[key], b[key]));
|
|
1044
|
+
}
|
|
1034
1045
|
function deepEqual(a, b) {
|
|
1035
1046
|
if (a === b) return true;
|
|
1036
1047
|
if (typeof a !== typeof b) return false;
|
|
1037
1048
|
if (typeof a !== "object" || a === null || b === null) return false;
|
|
1038
|
-
if (Array.isArray(a) && Array.isArray(b))
|
|
1039
|
-
if (a.length !== b.length) return false;
|
|
1040
|
-
return a.every((val, i) => deepEqual(val, b[i]));
|
|
1041
|
-
}
|
|
1049
|
+
if (Array.isArray(a) && Array.isArray(b)) return arraysEqual(a, b);
|
|
1042
1050
|
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
1043
|
-
|
|
1044
|
-
const keysB = Object.keys(b);
|
|
1045
|
-
if (keysA.length !== keysB.length) return false;
|
|
1046
|
-
return keysA.every(
|
|
1047
|
-
(key) => deepEqual(a[key], b[key])
|
|
1048
|
-
);
|
|
1051
|
+
return objectsEqual(a, b);
|
|
1049
1052
|
}
|
|
1050
1053
|
function stringArraysEqual(a, b) {
|
|
1051
1054
|
if (a.length !== b.length) return false;
|
|
@@ -1698,17 +1701,22 @@ async function parseDocumentationFile(path31) {
|
|
|
1698
1701
|
function makeInternalSymbol(name, type, line) {
|
|
1699
1702
|
return { name, type, line, references: 0, calledBy: [] };
|
|
1700
1703
|
}
|
|
1704
|
+
function extractFunctionSymbol(node, line) {
|
|
1705
|
+
if (node.id?.name) return [makeInternalSymbol(node.id.name, "function", line)];
|
|
1706
|
+
return [];
|
|
1707
|
+
}
|
|
1708
|
+
function extractVariableSymbols(node, line) {
|
|
1709
|
+
return (node.declarations || []).filter((decl) => decl.id?.name).map((decl) => makeInternalSymbol(decl.id.name, "variable", line));
|
|
1710
|
+
}
|
|
1711
|
+
function extractClassSymbol(node, line) {
|
|
1712
|
+
if (node.id?.name) return [makeInternalSymbol(node.id.name, "class", line)];
|
|
1713
|
+
return [];
|
|
1714
|
+
}
|
|
1701
1715
|
function extractSymbolsFromNode(node) {
|
|
1702
1716
|
const line = node.loc?.start?.line || 0;
|
|
1703
|
-
if (node.type === "FunctionDeclaration"
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
if (node.type === "VariableDeclaration") {
|
|
1707
|
-
return (node.declarations || []).filter((decl) => decl.id?.name).map((decl) => makeInternalSymbol(decl.id.name, "variable", line));
|
|
1708
|
-
}
|
|
1709
|
-
if (node.type === "ClassDeclaration" && node.id?.name) {
|
|
1710
|
-
return [makeInternalSymbol(node.id.name, "class", line)];
|
|
1711
|
-
}
|
|
1717
|
+
if (node.type === "FunctionDeclaration") return extractFunctionSymbol(node, line);
|
|
1718
|
+
if (node.type === "VariableDeclaration") return extractVariableSymbols(node, line);
|
|
1719
|
+
if (node.type === "ClassDeclaration") return extractClassSymbol(node, line);
|
|
1712
1720
|
return [];
|
|
1713
1721
|
}
|
|
1714
1722
|
function extractInternalSymbols(ast) {
|
|
@@ -1717,21 +1725,17 @@ function extractInternalSymbols(ast) {
|
|
|
1717
1725
|
const nodes = body.body;
|
|
1718
1726
|
return nodes.flatMap(extractSymbolsFromNode);
|
|
1719
1727
|
}
|
|
1728
|
+
function toJSDocComment(comment) {
|
|
1729
|
+
if (comment.type !== "Block" || !comment.value?.startsWith("*")) return null;
|
|
1730
|
+
return { content: comment.value, line: comment.loc?.start?.line || 0 };
|
|
1731
|
+
}
|
|
1720
1732
|
function extractJSDocComments(ast) {
|
|
1721
|
-
const comments = [];
|
|
1722
1733
|
const body = ast.body;
|
|
1723
|
-
if (body?.comments)
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
line: comment.loc?.start?.line || 0
|
|
1729
|
-
};
|
|
1730
|
-
comments.push(jsDocComment);
|
|
1731
|
-
}
|
|
1732
|
-
}
|
|
1733
|
-
}
|
|
1734
|
-
return comments;
|
|
1734
|
+
if (!body?.comments) return [];
|
|
1735
|
+
return body.comments.flatMap((c) => {
|
|
1736
|
+
const doc = toJSDocComment(c);
|
|
1737
|
+
return doc ? [doc] : [];
|
|
1738
|
+
});
|
|
1735
1739
|
}
|
|
1736
1740
|
function buildExportMap(files) {
|
|
1737
1741
|
const byFile = /* @__PURE__ */ new Map();
|
|
@@ -1746,41 +1750,42 @@ function buildExportMap(files) {
|
|
|
1746
1750
|
}
|
|
1747
1751
|
return { byFile, byName };
|
|
1748
1752
|
}
|
|
1749
|
-
|
|
1753
|
+
var CODE_BLOCK_LANGUAGES = /* @__PURE__ */ new Set(["typescript", "ts", "javascript", "js"]);
|
|
1754
|
+
function refsFromInlineRefs(doc) {
|
|
1755
|
+
return doc.inlineRefs.map((inlineRef) => ({
|
|
1756
|
+
docFile: doc.path,
|
|
1757
|
+
line: inlineRef.line,
|
|
1758
|
+
column: inlineRef.column,
|
|
1759
|
+
reference: inlineRef.reference,
|
|
1760
|
+
context: "inline"
|
|
1761
|
+
}));
|
|
1762
|
+
}
|
|
1763
|
+
function refsFromCodeBlock(docPath, block) {
|
|
1764
|
+
if (!CODE_BLOCK_LANGUAGES.has(block.language)) return [];
|
|
1750
1765
|
const refs = [];
|
|
1751
|
-
|
|
1752
|
-
|
|
1766
|
+
const importRegex = /import\s+\{([^}]+)\}\s+from/g;
|
|
1767
|
+
let match;
|
|
1768
|
+
while ((match = importRegex.exec(block.content)) !== null) {
|
|
1769
|
+
const group = match[1];
|
|
1770
|
+
if (group === void 0) continue;
|
|
1771
|
+
for (const name of group.split(",").map((n) => n.trim())) {
|
|
1753
1772
|
refs.push({
|
|
1754
|
-
docFile:
|
|
1755
|
-
line:
|
|
1756
|
-
column:
|
|
1757
|
-
reference:
|
|
1758
|
-
context: "
|
|
1773
|
+
docFile: docPath,
|
|
1774
|
+
line: block.line,
|
|
1775
|
+
column: 0,
|
|
1776
|
+
reference: name,
|
|
1777
|
+
context: "code-block"
|
|
1759
1778
|
});
|
|
1760
1779
|
}
|
|
1761
|
-
for (const block of doc.codeBlocks) {
|
|
1762
|
-
if (block.language === "typescript" || block.language === "ts" || block.language === "javascript" || block.language === "js") {
|
|
1763
|
-
const importRegex = /import\s+\{([^}]+)\}\s+from/g;
|
|
1764
|
-
let match;
|
|
1765
|
-
while ((match = importRegex.exec(block.content)) !== null) {
|
|
1766
|
-
const matchedGroup = match[1];
|
|
1767
|
-
if (matchedGroup === void 0) continue;
|
|
1768
|
-
const names = matchedGroup.split(",").map((n) => n.trim());
|
|
1769
|
-
for (const name of names) {
|
|
1770
|
-
refs.push({
|
|
1771
|
-
docFile: doc.path,
|
|
1772
|
-
line: block.line,
|
|
1773
|
-
column: 0,
|
|
1774
|
-
reference: name,
|
|
1775
|
-
context: "code-block"
|
|
1776
|
-
});
|
|
1777
|
-
}
|
|
1778
|
-
}
|
|
1779
|
-
}
|
|
1780
|
-
}
|
|
1781
1780
|
}
|
|
1782
1781
|
return refs;
|
|
1783
1782
|
}
|
|
1783
|
+
function refsFromCodeBlocks(doc) {
|
|
1784
|
+
return doc.codeBlocks.flatMap((block) => refsFromCodeBlock(doc.path, block));
|
|
1785
|
+
}
|
|
1786
|
+
function extractAllCodeReferences(docs) {
|
|
1787
|
+
return docs.flatMap((doc) => [...refsFromInlineRefs(doc), ...refsFromCodeBlocks(doc)]);
|
|
1788
|
+
}
|
|
1784
1789
|
async function buildSnapshot(config) {
|
|
1785
1790
|
const startTime = Date.now();
|
|
1786
1791
|
const parser = config.parser || new TypeScriptParser();
|
|
@@ -1986,44 +1991,52 @@ async function checkStructureDrift(snapshot, _config) {
|
|
|
1986
1991
|
}
|
|
1987
1992
|
return drifts;
|
|
1988
1993
|
}
|
|
1994
|
+
function computeDriftSeverity(driftCount) {
|
|
1995
|
+
if (driftCount === 0) return "none";
|
|
1996
|
+
if (driftCount <= 3) return "low";
|
|
1997
|
+
if (driftCount <= 10) return "medium";
|
|
1998
|
+
return "high";
|
|
1999
|
+
}
|
|
2000
|
+
function buildGraphDriftReport(graphDriftData) {
|
|
2001
|
+
const drifts = [];
|
|
2002
|
+
for (const target of graphDriftData.missingTargets) {
|
|
2003
|
+
drifts.push({
|
|
2004
|
+
type: "api-signature",
|
|
2005
|
+
docFile: target,
|
|
2006
|
+
line: 0,
|
|
2007
|
+
reference: target,
|
|
2008
|
+
context: "graph-missing-target",
|
|
2009
|
+
issue: "NOT_FOUND",
|
|
2010
|
+
details: `Graph node "${target}" has no matching code target`,
|
|
2011
|
+
confidence: "high"
|
|
2012
|
+
});
|
|
2013
|
+
}
|
|
2014
|
+
for (const edge of graphDriftData.staleEdges) {
|
|
2015
|
+
drifts.push({
|
|
2016
|
+
type: "api-signature",
|
|
2017
|
+
docFile: edge.docNodeId,
|
|
2018
|
+
line: 0,
|
|
2019
|
+
reference: edge.codeNodeId,
|
|
2020
|
+
context: `graph-stale-edge:${edge.edgeType}`,
|
|
2021
|
+
issue: "NOT_FOUND",
|
|
2022
|
+
details: `Stale edge from doc "${edge.docNodeId}" to code "${edge.codeNodeId}" (${edge.edgeType})`,
|
|
2023
|
+
confidence: "medium"
|
|
2024
|
+
});
|
|
2025
|
+
}
|
|
2026
|
+
return Ok({
|
|
2027
|
+
drifts,
|
|
2028
|
+
stats: {
|
|
2029
|
+
docsScanned: graphDriftData.staleEdges.length,
|
|
2030
|
+
referencesChecked: graphDriftData.staleEdges.length + graphDriftData.missingTargets.length,
|
|
2031
|
+
driftsFound: drifts.length,
|
|
2032
|
+
byType: { api: drifts.length, example: 0, structure: 0 }
|
|
2033
|
+
},
|
|
2034
|
+
severity: computeDriftSeverity(drifts.length)
|
|
2035
|
+
});
|
|
2036
|
+
}
|
|
1989
2037
|
async function detectDocDrift(snapshot, config, graphDriftData) {
|
|
1990
2038
|
if (graphDriftData) {
|
|
1991
|
-
|
|
1992
|
-
for (const target of graphDriftData.missingTargets) {
|
|
1993
|
-
drifts2.push({
|
|
1994
|
-
type: "api-signature",
|
|
1995
|
-
docFile: target,
|
|
1996
|
-
line: 0,
|
|
1997
|
-
reference: target,
|
|
1998
|
-
context: "graph-missing-target",
|
|
1999
|
-
issue: "NOT_FOUND",
|
|
2000
|
-
details: `Graph node "${target}" has no matching code target`,
|
|
2001
|
-
confidence: "high"
|
|
2002
|
-
});
|
|
2003
|
-
}
|
|
2004
|
-
for (const edge of graphDriftData.staleEdges) {
|
|
2005
|
-
drifts2.push({
|
|
2006
|
-
type: "api-signature",
|
|
2007
|
-
docFile: edge.docNodeId,
|
|
2008
|
-
line: 0,
|
|
2009
|
-
reference: edge.codeNodeId,
|
|
2010
|
-
context: `graph-stale-edge:${edge.edgeType}`,
|
|
2011
|
-
issue: "NOT_FOUND",
|
|
2012
|
-
details: `Stale edge from doc "${edge.docNodeId}" to code "${edge.codeNodeId}" (${edge.edgeType})`,
|
|
2013
|
-
confidence: "medium"
|
|
2014
|
-
});
|
|
2015
|
-
}
|
|
2016
|
-
const severity2 = drifts2.length === 0 ? "none" : drifts2.length <= 3 ? "low" : drifts2.length <= 10 ? "medium" : "high";
|
|
2017
|
-
return Ok({
|
|
2018
|
-
drifts: drifts2,
|
|
2019
|
-
stats: {
|
|
2020
|
-
docsScanned: graphDriftData.staleEdges.length,
|
|
2021
|
-
referencesChecked: graphDriftData.staleEdges.length + graphDriftData.missingTargets.length,
|
|
2022
|
-
driftsFound: drifts2.length,
|
|
2023
|
-
byType: { api: drifts2.length, example: 0, structure: 0 }
|
|
2024
|
-
},
|
|
2025
|
-
severity: severity2
|
|
2026
|
-
});
|
|
2039
|
+
return buildGraphDriftReport(graphDriftData);
|
|
2027
2040
|
}
|
|
2028
2041
|
const fullConfig = { ...DEFAULT_DRIFT_CONFIG, ...config };
|
|
2029
2042
|
const drifts = [];
|
|
@@ -2072,6 +2085,23 @@ function resolveImportToFile(importSource, fromFile, snapshot) {
|
|
|
2072
2085
|
}
|
|
2073
2086
|
return null;
|
|
2074
2087
|
}
|
|
2088
|
+
function enqueueResolved(sources, current, snapshot, visited, queue) {
|
|
2089
|
+
for (const item of sources) {
|
|
2090
|
+
if (!item.source) continue;
|
|
2091
|
+
const resolved = resolveImportToFile(item.source, current, snapshot);
|
|
2092
|
+
if (resolved && !visited.has(resolved)) {
|
|
2093
|
+
queue.push(resolved);
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
function processReachabilityNode(current, snapshot, reachability, visited, queue) {
|
|
2098
|
+
reachability.set(current, true);
|
|
2099
|
+
const sourceFile = snapshot.files.find((f) => f.path === current);
|
|
2100
|
+
if (!sourceFile) return;
|
|
2101
|
+
enqueueResolved(sourceFile.imports, current, snapshot, visited, queue);
|
|
2102
|
+
const reExports = sourceFile.exports.filter((e) => e.isReExport);
|
|
2103
|
+
enqueueResolved(reExports, current, snapshot, visited, queue);
|
|
2104
|
+
}
|
|
2075
2105
|
function buildReachabilityMap(snapshot) {
|
|
2076
2106
|
const reachability = /* @__PURE__ */ new Map();
|
|
2077
2107
|
for (const file of snapshot.files) {
|
|
@@ -2083,23 +2113,7 @@ function buildReachabilityMap(snapshot) {
|
|
|
2083
2113
|
const current = queue.shift();
|
|
2084
2114
|
if (visited.has(current)) continue;
|
|
2085
2115
|
visited.add(current);
|
|
2086
|
-
|
|
2087
|
-
const sourceFile = snapshot.files.find((f) => f.path === current);
|
|
2088
|
-
if (!sourceFile) continue;
|
|
2089
|
-
for (const imp of sourceFile.imports) {
|
|
2090
|
-
const resolved = resolveImportToFile(imp.source, current, snapshot);
|
|
2091
|
-
if (resolved && !visited.has(resolved)) {
|
|
2092
|
-
queue.push(resolved);
|
|
2093
|
-
}
|
|
2094
|
-
}
|
|
2095
|
-
for (const exp of sourceFile.exports) {
|
|
2096
|
-
if (exp.isReExport && exp.source) {
|
|
2097
|
-
const resolved = resolveImportToFile(exp.source, current, snapshot);
|
|
2098
|
-
if (resolved && !visited.has(resolved)) {
|
|
2099
|
-
queue.push(resolved);
|
|
2100
|
-
}
|
|
2101
|
-
}
|
|
2102
|
-
}
|
|
2116
|
+
processReachabilityNode(current, snapshot, reachability, visited, queue);
|
|
2103
2117
|
}
|
|
2104
2118
|
return reachability;
|
|
2105
2119
|
}
|
|
@@ -2169,21 +2183,27 @@ function findDeadExports(snapshot, usageMap, reachability) {
|
|
|
2169
2183
|
}
|
|
2170
2184
|
return deadExports;
|
|
2171
2185
|
}
|
|
2186
|
+
function maxLineOfValue(value) {
|
|
2187
|
+
if (Array.isArray(value)) {
|
|
2188
|
+
return value.reduce((m, item) => Math.max(m, findMaxLineInNode(item)), 0);
|
|
2189
|
+
}
|
|
2190
|
+
if (value && typeof value === "object") {
|
|
2191
|
+
return findMaxLineInNode(value);
|
|
2192
|
+
}
|
|
2193
|
+
return 0;
|
|
2194
|
+
}
|
|
2195
|
+
function maxLineOfNodeKeys(node) {
|
|
2196
|
+
let max = 0;
|
|
2197
|
+
for (const key of Object.keys(node)) {
|
|
2198
|
+
max = Math.max(max, maxLineOfValue(node[key]));
|
|
2199
|
+
}
|
|
2200
|
+
return max;
|
|
2201
|
+
}
|
|
2172
2202
|
function findMaxLineInNode(node) {
|
|
2173
2203
|
if (!node || typeof node !== "object") return 0;
|
|
2174
2204
|
const n = node;
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
const value = node[key];
|
|
2178
|
-
if (Array.isArray(value)) {
|
|
2179
|
-
for (const item of value) {
|
|
2180
|
-
maxLine = Math.max(maxLine, findMaxLineInNode(item));
|
|
2181
|
-
}
|
|
2182
|
-
} else if (value && typeof value === "object") {
|
|
2183
|
-
maxLine = Math.max(maxLine, findMaxLineInNode(value));
|
|
2184
|
-
}
|
|
2185
|
-
}
|
|
2186
|
-
return maxLine;
|
|
2205
|
+
const locLine = n.loc?.end?.line ?? 0;
|
|
2206
|
+
return Math.max(locLine, maxLineOfNodeKeys(node));
|
|
2187
2207
|
}
|
|
2188
2208
|
function countLinesFromAST(ast) {
|
|
2189
2209
|
if (!ast.body || !Array.isArray(ast.body)) return 1;
|
|
@@ -2257,54 +2277,59 @@ function findDeadInternals(snapshot, _reachability) {
|
|
|
2257
2277
|
}
|
|
2258
2278
|
return deadInternals;
|
|
2259
2279
|
}
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
type: exportType,
|
|
2281
|
-
isDefault: false,
|
|
2282
|
-
reason: "NO_IMPORTERS"
|
|
2283
|
-
});
|
|
2284
|
-
}
|
|
2285
|
-
}
|
|
2286
|
-
const reachableCount = graphDeadCodeData.reachableNodeIds instanceof Set ? graphDeadCodeData.reachableNodeIds.size : graphDeadCodeData.reachableNodeIds.length;
|
|
2287
|
-
const fileNodes = graphDeadCodeData.unreachableNodes.filter((n) => fileTypes.has(n.type));
|
|
2288
|
-
const exportNodes = graphDeadCodeData.unreachableNodes.filter((n) => exportTypes.has(n.type));
|
|
2289
|
-
const totalFiles = reachableCount + fileNodes.length;
|
|
2290
|
-
const totalExports2 = exportNodes.length + (reachableCount > 0 ? reachableCount : 0);
|
|
2291
|
-
const report2 = {
|
|
2292
|
-
deadExports: deadExports2,
|
|
2293
|
-
deadFiles: deadFiles2,
|
|
2294
|
-
deadInternals: [],
|
|
2295
|
-
unusedImports: [],
|
|
2296
|
-
stats: {
|
|
2297
|
-
filesAnalyzed: totalFiles,
|
|
2298
|
-
entryPointsUsed: [],
|
|
2299
|
-
totalExports: totalExports2,
|
|
2300
|
-
deadExportCount: deadExports2.length,
|
|
2301
|
-
totalFiles,
|
|
2302
|
-
deadFileCount: deadFiles2.length,
|
|
2303
|
-
estimatedDeadLines: 0
|
|
2304
|
-
}
|
|
2305
|
-
};
|
|
2306
|
-
return Ok(report2);
|
|
2280
|
+
var FILE_TYPES = /* @__PURE__ */ new Set(["file", "module"]);
|
|
2281
|
+
var EXPORT_TYPES = /* @__PURE__ */ new Set(["function", "class", "method", "interface", "variable"]);
|
|
2282
|
+
function classifyUnreachableNode(node, deadFiles, deadExports) {
|
|
2283
|
+
if (FILE_TYPES.has(node.type)) {
|
|
2284
|
+
deadFiles.push({
|
|
2285
|
+
path: node.path || node.id,
|
|
2286
|
+
reason: "NO_IMPORTERS",
|
|
2287
|
+
exportCount: 0,
|
|
2288
|
+
lineCount: 0
|
|
2289
|
+
});
|
|
2290
|
+
} else if (EXPORT_TYPES.has(node.type)) {
|
|
2291
|
+
const exportType = node.type === "method" ? "function" : node.type;
|
|
2292
|
+
deadExports.push({
|
|
2293
|
+
file: node.path || node.id,
|
|
2294
|
+
name: node.name,
|
|
2295
|
+
line: 0,
|
|
2296
|
+
type: exportType,
|
|
2297
|
+
isDefault: false,
|
|
2298
|
+
reason: "NO_IMPORTERS"
|
|
2299
|
+
});
|
|
2307
2300
|
}
|
|
2301
|
+
}
|
|
2302
|
+
function computeGraphReportStats(data, deadFiles, deadExports) {
|
|
2303
|
+
const reachableCount = data.reachableNodeIds instanceof Set ? data.reachableNodeIds.size : data.reachableNodeIds.length;
|
|
2304
|
+
const fileNodes = data.unreachableNodes.filter((n) => FILE_TYPES.has(n.type));
|
|
2305
|
+
const exportNodes = data.unreachableNodes.filter((n) => EXPORT_TYPES.has(n.type));
|
|
2306
|
+
const totalFiles = reachableCount + fileNodes.length;
|
|
2307
|
+
const totalExports = exportNodes.length + (reachableCount > 0 ? reachableCount : 0);
|
|
2308
|
+
return {
|
|
2309
|
+
filesAnalyzed: totalFiles,
|
|
2310
|
+
entryPointsUsed: [],
|
|
2311
|
+
totalExports,
|
|
2312
|
+
deadExportCount: deadExports.length,
|
|
2313
|
+
totalFiles,
|
|
2314
|
+
deadFileCount: deadFiles.length,
|
|
2315
|
+
estimatedDeadLines: 0
|
|
2316
|
+
};
|
|
2317
|
+
}
|
|
2318
|
+
function buildReportFromGraph(data) {
|
|
2319
|
+
const deadFiles = [];
|
|
2320
|
+
const deadExports = [];
|
|
2321
|
+
for (const node of data.unreachableNodes) {
|
|
2322
|
+
classifyUnreachableNode(node, deadFiles, deadExports);
|
|
2323
|
+
}
|
|
2324
|
+
return {
|
|
2325
|
+
deadExports,
|
|
2326
|
+
deadFiles,
|
|
2327
|
+
deadInternals: [],
|
|
2328
|
+
unusedImports: [],
|
|
2329
|
+
stats: computeGraphReportStats(data, deadFiles, deadExports)
|
|
2330
|
+
};
|
|
2331
|
+
}
|
|
2332
|
+
function buildReportFromSnapshot(snapshot) {
|
|
2308
2333
|
const reachability = buildReachabilityMap(snapshot);
|
|
2309
2334
|
const usageMap = buildExportUsageMap(snapshot);
|
|
2310
2335
|
const deadExports = findDeadExports(snapshot, usageMap, reachability);
|
|
@@ -2316,7 +2341,7 @@ async function detectDeadCode(snapshot, graphDeadCodeData) {
|
|
|
2316
2341
|
0
|
|
2317
2342
|
);
|
|
2318
2343
|
const estimatedDeadLines = deadFiles.reduce((acc, file) => acc + file.lineCount, 0);
|
|
2319
|
-
|
|
2344
|
+
return {
|
|
2320
2345
|
deadExports,
|
|
2321
2346
|
deadFiles,
|
|
2322
2347
|
deadInternals,
|
|
@@ -2331,6 +2356,9 @@ async function detectDeadCode(snapshot, graphDeadCodeData) {
|
|
|
2331
2356
|
estimatedDeadLines
|
|
2332
2357
|
}
|
|
2333
2358
|
};
|
|
2359
|
+
}
|
|
2360
|
+
async function detectDeadCode(snapshot, graphDeadCodeData) {
|
|
2361
|
+
const report = graphDeadCodeData ? buildReportFromGraph(graphDeadCodeData) : buildReportFromSnapshot(snapshot);
|
|
2334
2362
|
return Ok(report);
|
|
2335
2363
|
}
|
|
2336
2364
|
|
|
@@ -2614,48 +2642,52 @@ async function detectSizeBudgetViolations(rootDir, config) {
|
|
|
2614
2642
|
}
|
|
2615
2643
|
|
|
2616
2644
|
// src/entropy/fixers/suggestions.ts
|
|
2645
|
+
function deadFileSuggestion(file) {
|
|
2646
|
+
return {
|
|
2647
|
+
type: "delete",
|
|
2648
|
+
priority: "high",
|
|
2649
|
+
source: "dead-code",
|
|
2650
|
+
relatedIssues: [`dead-file:${file.path}`],
|
|
2651
|
+
title: `Remove dead file: ${file.path.split("/").pop()}`,
|
|
2652
|
+
description: `This file is not imported by any other file and can be safely removed.`,
|
|
2653
|
+
files: [file.path],
|
|
2654
|
+
steps: [`Delete ${file.path}`, "Run tests to verify no regressions"],
|
|
2655
|
+
whyManual: "File deletion requires verification that no dynamic imports exist"
|
|
2656
|
+
};
|
|
2657
|
+
}
|
|
2658
|
+
function deadExportSuggestion(exp) {
|
|
2659
|
+
return {
|
|
2660
|
+
type: "refactor",
|
|
2661
|
+
priority: "medium",
|
|
2662
|
+
source: "dead-code",
|
|
2663
|
+
relatedIssues: [`dead-export:${exp.file}:${exp.name}`],
|
|
2664
|
+
title: `Remove unused export: ${exp.name}`,
|
|
2665
|
+
description: `The export "${exp.name}" is not used anywhere. Consider removing it.`,
|
|
2666
|
+
files: [exp.file],
|
|
2667
|
+
steps: [`Remove export "${exp.name}" from ${exp.file}`, "Run tests to verify no regressions"],
|
|
2668
|
+
whyManual: "Export removal may affect external consumers not in scope"
|
|
2669
|
+
};
|
|
2670
|
+
}
|
|
2671
|
+
function unusedImportSuggestion(imp) {
|
|
2672
|
+
const plural = imp.specifiers.length > 1;
|
|
2673
|
+
return {
|
|
2674
|
+
type: "delete",
|
|
2675
|
+
priority: "medium",
|
|
2676
|
+
source: "dead-code",
|
|
2677
|
+
relatedIssues: [`unused-import:${imp.file}:${imp.specifiers.join(",")}`],
|
|
2678
|
+
title: `Remove unused import${plural ? "s" : ""}: ${imp.specifiers.join(", ")}`,
|
|
2679
|
+
description: `The import${plural ? "s" : ""} from "${imp.source}" ${plural ? "are" : "is"} not used.`,
|
|
2680
|
+
files: [imp.file],
|
|
2681
|
+
steps: imp.isFullyUnused ? [`Remove entire import line from ${imp.file}`] : [`Remove unused specifiers (${imp.specifiers.join(", ")}) from import statement`],
|
|
2682
|
+
whyManual: "Import removal can be auto-fixed"
|
|
2683
|
+
};
|
|
2684
|
+
}
|
|
2617
2685
|
function generateDeadCodeSuggestions(report) {
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
source: "dead-code",
|
|
2624
|
-
relatedIssues: [`dead-file:${file.path}`],
|
|
2625
|
-
title: `Remove dead file: ${file.path.split("/").pop()}`,
|
|
2626
|
-
description: `This file is not imported by any other file and can be safely removed.`,
|
|
2627
|
-
files: [file.path],
|
|
2628
|
-
steps: [`Delete ${file.path}`, "Run tests to verify no regressions"],
|
|
2629
|
-
whyManual: "File deletion requires verification that no dynamic imports exist"
|
|
2630
|
-
});
|
|
2631
|
-
}
|
|
2632
|
-
for (const exp of report.deadExports) {
|
|
2633
|
-
suggestions.push({
|
|
2634
|
-
type: "refactor",
|
|
2635
|
-
priority: "medium",
|
|
2636
|
-
source: "dead-code",
|
|
2637
|
-
relatedIssues: [`dead-export:${exp.file}:${exp.name}`],
|
|
2638
|
-
title: `Remove unused export: ${exp.name}`,
|
|
2639
|
-
description: `The export "${exp.name}" is not used anywhere. Consider removing it.`,
|
|
2640
|
-
files: [exp.file],
|
|
2641
|
-
steps: [`Remove export "${exp.name}" from ${exp.file}`, "Run tests to verify no regressions"],
|
|
2642
|
-
whyManual: "Export removal may affect external consumers not in scope"
|
|
2643
|
-
});
|
|
2644
|
-
}
|
|
2645
|
-
for (const imp of report.unusedImports) {
|
|
2646
|
-
suggestions.push({
|
|
2647
|
-
type: "delete",
|
|
2648
|
-
priority: "medium",
|
|
2649
|
-
source: "dead-code",
|
|
2650
|
-
relatedIssues: [`unused-import:${imp.file}:${imp.specifiers.join(",")}`],
|
|
2651
|
-
title: `Remove unused import${imp.specifiers.length > 1 ? "s" : ""}: ${imp.specifiers.join(", ")}`,
|
|
2652
|
-
description: `The import${imp.specifiers.length > 1 ? "s" : ""} from "${imp.source}" ${imp.specifiers.length > 1 ? "are" : "is"} not used.`,
|
|
2653
|
-
files: [imp.file],
|
|
2654
|
-
steps: imp.isFullyUnused ? [`Remove entire import line from ${imp.file}`] : [`Remove unused specifiers (${imp.specifiers.join(", ")}) from import statement`],
|
|
2655
|
-
whyManual: "Import removal can be auto-fixed"
|
|
2656
|
-
});
|
|
2657
|
-
}
|
|
2658
|
-
return suggestions;
|
|
2686
|
+
return [
|
|
2687
|
+
...report.deadFiles.map(deadFileSuggestion),
|
|
2688
|
+
...report.deadExports.map(deadExportSuggestion),
|
|
2689
|
+
...report.unusedImports.map(unusedImportSuggestion)
|
|
2690
|
+
];
|
|
2659
2691
|
}
|
|
2660
2692
|
function generateDriftSuggestions(report) {
|
|
2661
2693
|
const suggestions = [];
|
|
@@ -3098,43 +3130,55 @@ async function createBackup(filePath, backupDir) {
|
|
|
3098
3130
|
);
|
|
3099
3131
|
}
|
|
3100
3132
|
}
|
|
3133
|
+
async function applyDeleteFile(fix, config) {
|
|
3134
|
+
if (config.createBackup && config.backupDir) {
|
|
3135
|
+
const backupResult = await createBackup(fix.file, config.backupDir);
|
|
3136
|
+
if (!backupResult.ok) return Err({ fix, error: backupResult.error.message });
|
|
3137
|
+
}
|
|
3138
|
+
await unlink2(fix.file);
|
|
3139
|
+
return Ok(void 0);
|
|
3140
|
+
}
|
|
3141
|
+
async function applyDeleteLines(fix) {
|
|
3142
|
+
if (fix.line !== void 0) {
|
|
3143
|
+
const content = await readFile3(fix.file, "utf-8");
|
|
3144
|
+
const lines = content.split("\n");
|
|
3145
|
+
lines.splice(fix.line - 1, 1);
|
|
3146
|
+
await writeFile3(fix.file, lines.join("\n"));
|
|
3147
|
+
}
|
|
3148
|
+
}
|
|
3149
|
+
async function applyReplace(fix) {
|
|
3150
|
+
if (fix.oldContent && fix.newContent !== void 0) {
|
|
3151
|
+
const content = await readFile3(fix.file, "utf-8");
|
|
3152
|
+
await writeFile3(fix.file, content.replace(fix.oldContent, fix.newContent));
|
|
3153
|
+
}
|
|
3154
|
+
}
|
|
3155
|
+
async function applyInsert(fix) {
|
|
3156
|
+
if (fix.line !== void 0 && fix.newContent) {
|
|
3157
|
+
const content = await readFile3(fix.file, "utf-8");
|
|
3158
|
+
const lines = content.split("\n");
|
|
3159
|
+
lines.splice(fix.line - 1, 0, fix.newContent);
|
|
3160
|
+
await writeFile3(fix.file, lines.join("\n"));
|
|
3161
|
+
}
|
|
3162
|
+
}
|
|
3101
3163
|
async function applySingleFix(fix, config) {
|
|
3102
3164
|
if (config.dryRun) {
|
|
3103
3165
|
return Ok(fix);
|
|
3104
3166
|
}
|
|
3105
3167
|
try {
|
|
3106
3168
|
switch (fix.action) {
|
|
3107
|
-
case "delete-file":
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
if (!backupResult.ok) {
|
|
3111
|
-
return Err({ fix, error: backupResult.error.message });
|
|
3112
|
-
}
|
|
3113
|
-
}
|
|
3114
|
-
await unlink2(fix.file);
|
|
3169
|
+
case "delete-file": {
|
|
3170
|
+
const result = await applyDeleteFile(fix, config);
|
|
3171
|
+
if (!result.ok) return result;
|
|
3115
3172
|
break;
|
|
3173
|
+
}
|
|
3116
3174
|
case "delete-lines":
|
|
3117
|
-
|
|
3118
|
-
const content = await readFile3(fix.file, "utf-8");
|
|
3119
|
-
const lines = content.split("\n");
|
|
3120
|
-
lines.splice(fix.line - 1, 1);
|
|
3121
|
-
await writeFile3(fix.file, lines.join("\n"));
|
|
3122
|
-
}
|
|
3175
|
+
await applyDeleteLines(fix);
|
|
3123
3176
|
break;
|
|
3124
3177
|
case "replace":
|
|
3125
|
-
|
|
3126
|
-
const content = await readFile3(fix.file, "utf-8");
|
|
3127
|
-
const newContent = content.replace(fix.oldContent, fix.newContent);
|
|
3128
|
-
await writeFile3(fix.file, newContent);
|
|
3129
|
-
}
|
|
3178
|
+
await applyReplace(fix);
|
|
3130
3179
|
break;
|
|
3131
3180
|
case "insert":
|
|
3132
|
-
|
|
3133
|
-
const content = await readFile3(fix.file, "utf-8");
|
|
3134
|
-
const lines = content.split("\n");
|
|
3135
|
-
lines.splice(fix.line - 1, 0, fix.newContent);
|
|
3136
|
-
await writeFile3(fix.file, lines.join("\n"));
|
|
3137
|
-
}
|
|
3181
|
+
await applyInsert(fix);
|
|
3138
3182
|
break;
|
|
3139
3183
|
}
|
|
3140
3184
|
return Ok(fix);
|
|
@@ -3307,6 +3351,21 @@ function applyHotspotDowngrade(finding, hotspot) {
|
|
|
3307
3351
|
}
|
|
3308
3352
|
return finding;
|
|
3309
3353
|
}
|
|
3354
|
+
function mergeGroup(group) {
|
|
3355
|
+
if (group.length === 1) return [group[0]];
|
|
3356
|
+
const deadCode = group.find((f) => f.concern === "dead-code");
|
|
3357
|
+
const arch = group.find((f) => f.concern === "architecture");
|
|
3358
|
+
if (deadCode && arch) {
|
|
3359
|
+
return [
|
|
3360
|
+
{
|
|
3361
|
+
...deadCode,
|
|
3362
|
+
description: `${deadCode.description} (also violates architecture: ${arch.type})`,
|
|
3363
|
+
suggestion: deadCode.fixAction ? `${deadCode.fixAction} (resolves both dead code and architecture violation)` : deadCode.suggestion
|
|
3364
|
+
}
|
|
3365
|
+
];
|
|
3366
|
+
}
|
|
3367
|
+
return group;
|
|
3368
|
+
}
|
|
3310
3369
|
function deduplicateCleanupFindings(findings) {
|
|
3311
3370
|
const byFileAndLine = /* @__PURE__ */ new Map();
|
|
3312
3371
|
for (const f of findings) {
|
|
@@ -3317,21 +3376,7 @@ function deduplicateCleanupFindings(findings) {
|
|
|
3317
3376
|
}
|
|
3318
3377
|
const result = [];
|
|
3319
3378
|
for (const group of byFileAndLine.values()) {
|
|
3320
|
-
|
|
3321
|
-
result.push(group[0]);
|
|
3322
|
-
continue;
|
|
3323
|
-
}
|
|
3324
|
-
const deadCode = group.find((f) => f.concern === "dead-code");
|
|
3325
|
-
const arch = group.find((f) => f.concern === "architecture");
|
|
3326
|
-
if (deadCode && arch) {
|
|
3327
|
-
result.push({
|
|
3328
|
-
...deadCode,
|
|
3329
|
-
description: `${deadCode.description} (also violates architecture: ${arch.type})`,
|
|
3330
|
-
suggestion: deadCode.fixAction ? `${deadCode.fixAction} (resolves both dead code and architecture violation)` : deadCode.suggestion
|
|
3331
|
-
});
|
|
3332
|
-
} else {
|
|
3333
|
-
result.push(...group);
|
|
3334
|
-
}
|
|
3379
|
+
result.push(...mergeGroup(group));
|
|
3335
3380
|
}
|
|
3336
3381
|
return result;
|
|
3337
3382
|
}
|
|
@@ -3704,6 +3749,32 @@ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", ".git"]);
|
|
|
3704
3749
|
var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx"]);
|
|
3705
3750
|
var FUNCTION_DECL_RE = /(?:export\s+)?(?:async\s+)?function\s+(\w+)/;
|
|
3706
3751
|
var CONST_DECL_RE = /(?:export\s+)?(?:const|let)\s+(\w+)\s*=/;
|
|
3752
|
+
function mergeGraphInferred(highFanInFunctions, seen) {
|
|
3753
|
+
let added = 0;
|
|
3754
|
+
for (const item of highFanInFunctions) {
|
|
3755
|
+
const key = `${item.file}::${item.function}`;
|
|
3756
|
+
if (!seen.has(key)) {
|
|
3757
|
+
seen.set(key, {
|
|
3758
|
+
file: item.file,
|
|
3759
|
+
function: item.function,
|
|
3760
|
+
source: "graph-inferred",
|
|
3761
|
+
fanIn: item.fanIn
|
|
3762
|
+
});
|
|
3763
|
+
added++;
|
|
3764
|
+
}
|
|
3765
|
+
}
|
|
3766
|
+
return added;
|
|
3767
|
+
}
|
|
3768
|
+
function isCommentOrBlank(line) {
|
|
3769
|
+
return line === "" || line === "*/" || line === "*" || line.startsWith("*") || line.startsWith("//");
|
|
3770
|
+
}
|
|
3771
|
+
function matchDeclarationName(line) {
|
|
3772
|
+
const funcMatch = line.match(FUNCTION_DECL_RE);
|
|
3773
|
+
if (funcMatch?.[1]) return funcMatch[1];
|
|
3774
|
+
const constMatch = line.match(CONST_DECL_RE);
|
|
3775
|
+
if (constMatch?.[1]) return constMatch[1];
|
|
3776
|
+
return null;
|
|
3777
|
+
}
|
|
3707
3778
|
var CriticalPathResolver = class {
|
|
3708
3779
|
projectRoot;
|
|
3709
3780
|
constructor(projectRoot) {
|
|
@@ -3716,27 +3787,12 @@ var CriticalPathResolver = class {
|
|
|
3716
3787
|
const key = `${entry.file}::${entry.function}`;
|
|
3717
3788
|
seen.set(key, entry);
|
|
3718
3789
|
}
|
|
3719
|
-
|
|
3720
|
-
if (graphData) {
|
|
3721
|
-
for (const item of graphData.highFanInFunctions) {
|
|
3722
|
-
const key = `${item.file}::${item.function}`;
|
|
3723
|
-
if (!seen.has(key)) {
|
|
3724
|
-
seen.set(key, {
|
|
3725
|
-
file: item.file,
|
|
3726
|
-
function: item.function,
|
|
3727
|
-
source: "graph-inferred",
|
|
3728
|
-
fanIn: item.fanIn
|
|
3729
|
-
});
|
|
3730
|
-
graphInferred++;
|
|
3731
|
-
}
|
|
3732
|
-
}
|
|
3733
|
-
}
|
|
3790
|
+
const graphInferred = graphData ? mergeGraphInferred(graphData.highFanInFunctions, seen) : 0;
|
|
3734
3791
|
const entries = Array.from(seen.values());
|
|
3735
|
-
const annotatedCount = annotated.length;
|
|
3736
3792
|
return {
|
|
3737
3793
|
entries,
|
|
3738
3794
|
stats: {
|
|
3739
|
-
annotated:
|
|
3795
|
+
annotated: annotated.length,
|
|
3740
3796
|
graphInferred,
|
|
3741
3797
|
total: entries.length
|
|
3742
3798
|
}
|
|
@@ -3763,6 +3819,14 @@ var CriticalPathResolver = class {
|
|
|
3763
3819
|
}
|
|
3764
3820
|
}
|
|
3765
3821
|
}
|
|
3822
|
+
resolveFunctionName(lines, fromIndex) {
|
|
3823
|
+
for (let j = fromIndex; j < lines.length; j++) {
|
|
3824
|
+
const nextLine = lines[j].trim();
|
|
3825
|
+
if (isCommentOrBlank(nextLine)) continue;
|
|
3826
|
+
return matchDeclarationName(nextLine);
|
|
3827
|
+
}
|
|
3828
|
+
return null;
|
|
3829
|
+
}
|
|
3766
3830
|
scanFile(filePath, entries) {
|
|
3767
3831
|
let content;
|
|
3768
3832
|
try {
|
|
@@ -3773,30 +3837,10 @@ var CriticalPathResolver = class {
|
|
|
3773
3837
|
const lines = content.split("\n");
|
|
3774
3838
|
const relativePath = path.relative(this.projectRoot, filePath).replace(/\\/g, "/");
|
|
3775
3839
|
for (let i = 0; i < lines.length; i++) {
|
|
3776
|
-
|
|
3777
|
-
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
if (nextLine === "" || nextLine === "*/" || nextLine === "*") continue;
|
|
3781
|
-
if (nextLine.startsWith("*") || nextLine.startsWith("//")) continue;
|
|
3782
|
-
const funcMatch = nextLine.match(FUNCTION_DECL_RE);
|
|
3783
|
-
if (funcMatch && funcMatch[1]) {
|
|
3784
|
-
entries.push({
|
|
3785
|
-
file: relativePath,
|
|
3786
|
-
function: funcMatch[1],
|
|
3787
|
-
source: "annotation"
|
|
3788
|
-
});
|
|
3789
|
-
} else {
|
|
3790
|
-
const constMatch = nextLine.match(CONST_DECL_RE);
|
|
3791
|
-
if (constMatch && constMatch[1]) {
|
|
3792
|
-
entries.push({
|
|
3793
|
-
file: relativePath,
|
|
3794
|
-
function: constMatch[1],
|
|
3795
|
-
source: "annotation"
|
|
3796
|
-
});
|
|
3797
|
-
}
|
|
3798
|
-
}
|
|
3799
|
-
break;
|
|
3840
|
+
if (!lines[i].includes("@perf-critical")) continue;
|
|
3841
|
+
const fnName = this.resolveFunctionName(lines, i + 1);
|
|
3842
|
+
if (fnName) {
|
|
3843
|
+
entries.push({ file: relativePath, function: fnName, source: "annotation" });
|
|
3800
3844
|
}
|
|
3801
3845
|
}
|
|
3802
3846
|
}
|
|
@@ -3951,14 +3995,19 @@ function detectFileStatus(part) {
|
|
|
3951
3995
|
if (part.includes("rename from")) return "renamed";
|
|
3952
3996
|
return "modified";
|
|
3953
3997
|
}
|
|
3954
|
-
function
|
|
3998
|
+
function parseDiffHeader(part) {
|
|
3955
3999
|
if (!part.trim()) return null;
|
|
3956
4000
|
const headerMatch = /diff --git a\/(.+?) b\/(.+?)(?:\n|$)/.exec(part);
|
|
3957
4001
|
if (!headerMatch || !headerMatch[2]) return null;
|
|
3958
|
-
|
|
4002
|
+
return headerMatch[2];
|
|
4003
|
+
}
|
|
4004
|
+
function parseDiffPart(part) {
|
|
4005
|
+
const path31 = parseDiffHeader(part);
|
|
4006
|
+
if (!path31) return null;
|
|
4007
|
+
const additionRegex = /^\+(?!\+\+)/gm;
|
|
3959
4008
|
const deletionRegex = /^-(?!--)/gm;
|
|
3960
4009
|
return {
|
|
3961
|
-
path:
|
|
4010
|
+
path: path31,
|
|
3962
4011
|
status: detectFileStatus(part),
|
|
3963
4012
|
additions: (part.match(additionRegex) || []).length,
|
|
3964
4013
|
deletions: (part.match(deletionRegex) || []).length
|
|
@@ -3980,100 +4029,136 @@ function parseDiff(diff2) {
|
|
|
3980
4029
|
});
|
|
3981
4030
|
}
|
|
3982
4031
|
}
|
|
3983
|
-
|
|
3984
|
-
|
|
3985
|
-
|
|
4032
|
+
function checkForbiddenPatterns(diff2, forbiddenPatterns, nextId) {
|
|
4033
|
+
const items = [];
|
|
4034
|
+
if (!forbiddenPatterns) return items;
|
|
4035
|
+
for (const forbidden of forbiddenPatterns) {
|
|
4036
|
+
const pattern = typeof forbidden.pattern === "string" ? new RegExp(forbidden.pattern, "g") : forbidden.pattern;
|
|
4037
|
+
if (!pattern.test(diff2)) continue;
|
|
4038
|
+
items.push({
|
|
4039
|
+
id: nextId(),
|
|
4040
|
+
category: "diff",
|
|
4041
|
+
check: `Forbidden pattern: ${forbidden.pattern}`,
|
|
4042
|
+
passed: false,
|
|
4043
|
+
severity: forbidden.severity,
|
|
4044
|
+
details: forbidden.message,
|
|
4045
|
+
suggestion: `Remove occurrences of ${forbidden.pattern}`
|
|
4046
|
+
});
|
|
3986
4047
|
}
|
|
4048
|
+
return items;
|
|
4049
|
+
}
|
|
4050
|
+
function checkMaxChangedFiles(files, maxChangedFiles, nextId) {
|
|
4051
|
+
if (!maxChangedFiles || files.length <= maxChangedFiles) return [];
|
|
4052
|
+
return [
|
|
4053
|
+
{
|
|
4054
|
+
id: nextId(),
|
|
4055
|
+
category: "diff",
|
|
4056
|
+
check: `PR size: ${files.length} files changed`,
|
|
4057
|
+
passed: false,
|
|
4058
|
+
severity: "warning",
|
|
4059
|
+
details: `This PR changes ${files.length} files, which exceeds the recommended maximum of ${maxChangedFiles}`,
|
|
4060
|
+
suggestion: "Consider breaking this into smaller PRs"
|
|
4061
|
+
}
|
|
4062
|
+
];
|
|
4063
|
+
}
|
|
4064
|
+
function checkFileSizes(files, maxFileSize, nextId) {
|
|
3987
4065
|
const items = [];
|
|
3988
|
-
|
|
3989
|
-
|
|
3990
|
-
|
|
3991
|
-
|
|
3992
|
-
|
|
3993
|
-
|
|
3994
|
-
|
|
3995
|
-
|
|
3996
|
-
|
|
3997
|
-
|
|
3998
|
-
|
|
3999
|
-
|
|
4000
|
-
|
|
4001
|
-
|
|
4002
|
-
|
|
4066
|
+
if (!maxFileSize) return items;
|
|
4067
|
+
for (const file of files) {
|
|
4068
|
+
const totalLines = file.additions + file.deletions;
|
|
4069
|
+
if (totalLines <= maxFileSize) continue;
|
|
4070
|
+
items.push({
|
|
4071
|
+
id: nextId(),
|
|
4072
|
+
category: "diff",
|
|
4073
|
+
check: `File size: ${file.path}`,
|
|
4074
|
+
passed: false,
|
|
4075
|
+
severity: "warning",
|
|
4076
|
+
details: `File has ${totalLines} lines changed, exceeding limit of ${maxFileSize}`,
|
|
4077
|
+
file: file.path,
|
|
4078
|
+
suggestion: "Consider splitting this file into smaller modules"
|
|
4079
|
+
});
|
|
4080
|
+
}
|
|
4081
|
+
return items;
|
|
4082
|
+
}
|
|
4083
|
+
function checkTestCoverageGraph(files, graphImpactData) {
|
|
4084
|
+
const items = [];
|
|
4085
|
+
for (const file of files) {
|
|
4086
|
+
if (file.status !== "added" || !file.path.endsWith(".ts") || file.path.includes(".test.")) {
|
|
4087
|
+
continue;
|
|
4003
4088
|
}
|
|
4089
|
+
const hasGraphTest = graphImpactData.affectedTests.some((t) => t.coversFile === file.path);
|
|
4090
|
+
if (hasGraphTest) continue;
|
|
4091
|
+
items.push({
|
|
4092
|
+
id: `test-coverage-${file.path}`,
|
|
4093
|
+
category: "diff",
|
|
4094
|
+
check: "Test coverage (graph)",
|
|
4095
|
+
passed: false,
|
|
4096
|
+
severity: "warning",
|
|
4097
|
+
details: `New file ${file.path} has no test file linked in the graph`,
|
|
4098
|
+
file: file.path
|
|
4099
|
+
});
|
|
4004
4100
|
}
|
|
4005
|
-
|
|
4101
|
+
return items;
|
|
4102
|
+
}
|
|
4103
|
+
function checkTestCoverageFilename(files, nextId) {
|
|
4104
|
+
const items = [];
|
|
4105
|
+
const addedSourceFiles = files.filter(
|
|
4106
|
+
(f) => f.status === "added" && f.path.endsWith(".ts") && !f.path.includes(".test.")
|
|
4107
|
+
);
|
|
4108
|
+
const testFiles = files.filter((f) => f.path.includes(".test."));
|
|
4109
|
+
for (const sourceFile of addedSourceFiles) {
|
|
4110
|
+
const expectedTestPath = sourceFile.path.replace(".ts", ".test.ts");
|
|
4111
|
+
const hasTest = testFiles.some(
|
|
4112
|
+
(t) => t.path.includes(expectedTestPath) || t.path.includes(sourceFile.path.replace(".ts", ""))
|
|
4113
|
+
);
|
|
4114
|
+
if (hasTest) continue;
|
|
4006
4115
|
items.push({
|
|
4007
|
-
id:
|
|
4116
|
+
id: nextId(),
|
|
4008
4117
|
category: "diff",
|
|
4009
|
-
check: `
|
|
4118
|
+
check: `Test coverage: ${sourceFile.path}`,
|
|
4010
4119
|
passed: false,
|
|
4011
4120
|
severity: "warning",
|
|
4012
|
-
details:
|
|
4013
|
-
|
|
4121
|
+
details: "New source file added without corresponding test file",
|
|
4122
|
+
file: sourceFile.path,
|
|
4123
|
+
suggestion: `Add tests in ${expectedTestPath}`
|
|
4014
4124
|
});
|
|
4015
4125
|
}
|
|
4016
|
-
|
|
4017
|
-
|
|
4018
|
-
|
|
4019
|
-
|
|
4020
|
-
|
|
4021
|
-
|
|
4022
|
-
|
|
4023
|
-
check: `File size: ${file.path}`,
|
|
4024
|
-
passed: false,
|
|
4025
|
-
severity: "warning",
|
|
4026
|
-
details: `File has ${totalLines} lines changed, exceeding limit of ${options.maxFileSize}`,
|
|
4027
|
-
file: file.path,
|
|
4028
|
-
suggestion: "Consider splitting this file into smaller modules"
|
|
4029
|
-
});
|
|
4030
|
-
}
|
|
4126
|
+
return items;
|
|
4127
|
+
}
|
|
4128
|
+
function checkDocCoverage2(files, graphImpactData) {
|
|
4129
|
+
const items = [];
|
|
4130
|
+
for (const file of files) {
|
|
4131
|
+
if (file.status !== "modified" || !file.path.endsWith(".ts") || file.path.includes(".test.")) {
|
|
4132
|
+
continue;
|
|
4031
4133
|
}
|
|
4134
|
+
const hasDoc = graphImpactData.affectedDocs.some((d) => d.documentsFile === file.path);
|
|
4135
|
+
if (hasDoc) continue;
|
|
4136
|
+
items.push({
|
|
4137
|
+
id: `doc-coverage-${file.path}`,
|
|
4138
|
+
category: "diff",
|
|
4139
|
+
check: "Documentation coverage (graph)",
|
|
4140
|
+
passed: true,
|
|
4141
|
+
severity: "info",
|
|
4142
|
+
details: `Modified file ${file.path} has no documentation linked in the graph`,
|
|
4143
|
+
file: file.path
|
|
4144
|
+
});
|
|
4145
|
+
}
|
|
4146
|
+
return items;
|
|
4147
|
+
}
|
|
4148
|
+
async function analyzeDiff(changes, options, graphImpactData) {
|
|
4149
|
+
if (!options?.enabled) {
|
|
4150
|
+
return Ok([]);
|
|
4032
4151
|
}
|
|
4152
|
+
let itemId = 0;
|
|
4153
|
+
const nextId = () => `diff-${++itemId}`;
|
|
4154
|
+
const items = [
|
|
4155
|
+
...checkForbiddenPatterns(changes.diff, options.forbiddenPatterns, nextId),
|
|
4156
|
+
...checkMaxChangedFiles(changes.files, options.maxChangedFiles, nextId),
|
|
4157
|
+
...checkFileSizes(changes.files, options.maxFileSize, nextId)
|
|
4158
|
+
];
|
|
4033
4159
|
if (options.checkTestCoverage) {
|
|
4034
|
-
|
|
4035
|
-
|
|
4036
|
-
if (file.status === "added" && file.path.endsWith(".ts") && !file.path.includes(".test.")) {
|
|
4037
|
-
const hasGraphTest = graphImpactData.affectedTests.some(
|
|
4038
|
-
(t) => t.coversFile === file.path
|
|
4039
|
-
);
|
|
4040
|
-
if (!hasGraphTest) {
|
|
4041
|
-
items.push({
|
|
4042
|
-
id: `test-coverage-${file.path}`,
|
|
4043
|
-
category: "diff",
|
|
4044
|
-
check: "Test coverage (graph)",
|
|
4045
|
-
passed: false,
|
|
4046
|
-
severity: "warning",
|
|
4047
|
-
details: `New file ${file.path} has no test file linked in the graph`,
|
|
4048
|
-
file: file.path
|
|
4049
|
-
});
|
|
4050
|
-
}
|
|
4051
|
-
}
|
|
4052
|
-
}
|
|
4053
|
-
} else {
|
|
4054
|
-
const addedSourceFiles = changes.files.filter(
|
|
4055
|
-
(f) => f.status === "added" && f.path.endsWith(".ts") && !f.path.includes(".test.")
|
|
4056
|
-
);
|
|
4057
|
-
const testFiles = changes.files.filter((f) => f.path.includes(".test."));
|
|
4058
|
-
for (const sourceFile of addedSourceFiles) {
|
|
4059
|
-
const expectedTestPath = sourceFile.path.replace(".ts", ".test.ts");
|
|
4060
|
-
const hasTest = testFiles.some(
|
|
4061
|
-
(t) => t.path.includes(expectedTestPath) || t.path.includes(sourceFile.path.replace(".ts", ""))
|
|
4062
|
-
);
|
|
4063
|
-
if (!hasTest) {
|
|
4064
|
-
items.push({
|
|
4065
|
-
id: `diff-${++itemId}`,
|
|
4066
|
-
category: "diff",
|
|
4067
|
-
check: `Test coverage: ${sourceFile.path}`,
|
|
4068
|
-
passed: false,
|
|
4069
|
-
severity: "warning",
|
|
4070
|
-
details: "New source file added without corresponding test file",
|
|
4071
|
-
file: sourceFile.path,
|
|
4072
|
-
suggestion: `Add tests in ${expectedTestPath}`
|
|
4073
|
-
});
|
|
4074
|
-
}
|
|
4075
|
-
}
|
|
4076
|
-
}
|
|
4160
|
+
const coverageItems = graphImpactData ? checkTestCoverageGraph(changes.files, graphImpactData) : checkTestCoverageFilename(changes.files, nextId);
|
|
4161
|
+
items.push(...coverageItems);
|
|
4077
4162
|
}
|
|
4078
4163
|
if (graphImpactData && graphImpactData.impactScope > 20) {
|
|
4079
4164
|
items.push({
|
|
@@ -4086,22 +4171,7 @@ async function analyzeDiff(changes, options, graphImpactData) {
|
|
|
4086
4171
|
});
|
|
4087
4172
|
}
|
|
4088
4173
|
if (graphImpactData) {
|
|
4089
|
-
|
|
4090
|
-
if (file.status === "modified" && file.path.endsWith(".ts") && !file.path.includes(".test.")) {
|
|
4091
|
-
const hasDoc = graphImpactData.affectedDocs.some((d) => d.documentsFile === file.path);
|
|
4092
|
-
if (!hasDoc) {
|
|
4093
|
-
items.push({
|
|
4094
|
-
id: `doc-coverage-${file.path}`,
|
|
4095
|
-
category: "diff",
|
|
4096
|
-
check: "Documentation coverage (graph)",
|
|
4097
|
-
passed: true,
|
|
4098
|
-
severity: "info",
|
|
4099
|
-
details: `Modified file ${file.path} has no documentation linked in the graph`,
|
|
4100
|
-
file: file.path
|
|
4101
|
-
});
|
|
4102
|
-
}
|
|
4103
|
-
}
|
|
4104
|
-
}
|
|
4174
|
+
items.push(...checkDocCoverage2(changes.files, graphImpactData));
|
|
4105
4175
|
}
|
|
4106
4176
|
return Ok(items);
|
|
4107
4177
|
}
|
|
@@ -4634,10 +4704,26 @@ function hasMatchingViolation(rule, violationsByCategory) {
|
|
|
4634
4704
|
}
|
|
4635
4705
|
|
|
4636
4706
|
// src/architecture/detect-stale.ts
|
|
4707
|
+
function evaluateStaleNode(node, now, cutoff) {
|
|
4708
|
+
const lastViolatedAt = node.lastViolatedAt ?? null;
|
|
4709
|
+
const createdAt = node.createdAt;
|
|
4710
|
+
const comparisonTimestamp = lastViolatedAt ?? createdAt;
|
|
4711
|
+
if (!comparisonTimestamp) return null;
|
|
4712
|
+
const timestampMs = new Date(comparisonTimestamp).getTime();
|
|
4713
|
+
if (timestampMs >= cutoff) return null;
|
|
4714
|
+
const daysSince = Math.floor((now - timestampMs) / (24 * 60 * 60 * 1e3));
|
|
4715
|
+
return {
|
|
4716
|
+
id: node.id,
|
|
4717
|
+
category: node.category,
|
|
4718
|
+
description: node.name ?? "",
|
|
4719
|
+
scope: node.scope ?? "project",
|
|
4720
|
+
lastViolatedAt,
|
|
4721
|
+
daysSinceLastViolation: daysSince
|
|
4722
|
+
};
|
|
4723
|
+
}
|
|
4637
4724
|
function detectStaleConstraints(store, windowDays = 30, category) {
|
|
4638
4725
|
const now = Date.now();
|
|
4639
|
-
const
|
|
4640
|
-
const cutoff = now - windowMs;
|
|
4726
|
+
const cutoff = now - windowDays * 24 * 60 * 60 * 1e3;
|
|
4641
4727
|
let constraints = store.findNodes({ type: "constraint" });
|
|
4642
4728
|
if (category) {
|
|
4643
4729
|
constraints = constraints.filter((n) => n.category === category);
|
|
@@ -4645,28 +4731,23 @@ function detectStaleConstraints(store, windowDays = 30, category) {
|
|
|
4645
4731
|
const totalConstraints = constraints.length;
|
|
4646
4732
|
const staleConstraints = [];
|
|
4647
4733
|
for (const node of constraints) {
|
|
4648
|
-
const
|
|
4649
|
-
|
|
4650
|
-
const comparisonTimestamp = lastViolatedAt ?? createdAt;
|
|
4651
|
-
if (!comparisonTimestamp) continue;
|
|
4652
|
-
const timestampMs = new Date(comparisonTimestamp).getTime();
|
|
4653
|
-
if (timestampMs < cutoff) {
|
|
4654
|
-
const daysSince = Math.floor((now - timestampMs) / (24 * 60 * 60 * 1e3));
|
|
4655
|
-
staleConstraints.push({
|
|
4656
|
-
id: node.id,
|
|
4657
|
-
category: node.category,
|
|
4658
|
-
description: node.name ?? "",
|
|
4659
|
-
scope: node.scope ?? "project",
|
|
4660
|
-
lastViolatedAt,
|
|
4661
|
-
daysSinceLastViolation: daysSince
|
|
4662
|
-
});
|
|
4663
|
-
}
|
|
4734
|
+
const entry = evaluateStaleNode(node, now, cutoff);
|
|
4735
|
+
if (entry) staleConstraints.push(entry);
|
|
4664
4736
|
}
|
|
4665
4737
|
staleConstraints.sort((a, b) => b.daysSinceLastViolation - a.daysSinceLastViolation);
|
|
4666
4738
|
return { staleConstraints, totalConstraints, windowDays };
|
|
4667
4739
|
}
|
|
4668
4740
|
|
|
4669
4741
|
// src/architecture/config.ts
|
|
4742
|
+
function mergeThresholdCategory(projectValue2, moduleValue) {
|
|
4743
|
+
if (projectValue2 !== void 0 && typeof projectValue2 === "object" && !Array.isArray(projectValue2) && typeof moduleValue === "object" && !Array.isArray(moduleValue)) {
|
|
4744
|
+
return {
|
|
4745
|
+
...projectValue2,
|
|
4746
|
+
...moduleValue
|
|
4747
|
+
};
|
|
4748
|
+
}
|
|
4749
|
+
return moduleValue;
|
|
4750
|
+
}
|
|
4670
4751
|
function resolveThresholds(scope, config) {
|
|
4671
4752
|
const projectThresholds = {};
|
|
4672
4753
|
for (const [key, val] of Object.entries(config.thresholds)) {
|
|
@@ -4682,14 +4763,7 @@ function resolveThresholds(scope, config) {
|
|
|
4682
4763
|
const merged = { ...projectThresholds };
|
|
4683
4764
|
for (const [category, moduleValue] of Object.entries(moduleOverrides)) {
|
|
4684
4765
|
const projectValue2 = projectThresholds[category];
|
|
4685
|
-
|
|
4686
|
-
merged[category] = {
|
|
4687
|
-
...projectValue2,
|
|
4688
|
-
...moduleValue
|
|
4689
|
-
};
|
|
4690
|
-
} else {
|
|
4691
|
-
merged[category] = moduleValue;
|
|
4692
|
-
}
|
|
4766
|
+
merged[category] = mergeThresholdCategory(projectValue2, moduleValue);
|
|
4693
4767
|
}
|
|
4694
4768
|
return merged;
|
|
4695
4769
|
}
|
|
@@ -5313,18 +5387,10 @@ var PredictionEngine = class {
|
|
|
5313
5387
|
*/
|
|
5314
5388
|
predict(options) {
|
|
5315
5389
|
const opts = this.resolveOptions(options);
|
|
5316
|
-
const
|
|
5317
|
-
const snapshots = timeline.snapshots;
|
|
5318
|
-
if (snapshots.length < 3) {
|
|
5319
|
-
throw new Error(
|
|
5320
|
-
`PredictionEngine requires at least 3 snapshots, got ${snapshots.length}. Run "harness snapshot" to capture more data points.`
|
|
5321
|
-
);
|
|
5322
|
-
}
|
|
5390
|
+
const snapshots = this.loadValidatedSnapshots();
|
|
5323
5391
|
const thresholds = this.resolveThresholds(opts);
|
|
5324
5392
|
const categoriesToProcess = opts.categories ?? [...ALL_CATEGORIES2];
|
|
5325
|
-
const firstDate =
|
|
5326
|
-
const lastSnapshot = snapshots[snapshots.length - 1];
|
|
5327
|
-
const currentT = (new Date(lastSnapshot.capturedAt).getTime() - firstDate) / (7 * 24 * 60 * 60 * 1e3);
|
|
5393
|
+
const { firstDate, lastSnapshot, currentT } = this.computeTimeOffsets(snapshots);
|
|
5328
5394
|
const baselines = this.computeBaselines(
|
|
5329
5395
|
categoriesToProcess,
|
|
5330
5396
|
thresholds,
|
|
@@ -5335,27 +5401,32 @@ var PredictionEngine = class {
|
|
|
5335
5401
|
);
|
|
5336
5402
|
const specImpacts = this.computeSpecImpacts(opts);
|
|
5337
5403
|
const categories = this.computeAdjustedForecasts(baselines, thresholds, specImpacts, currentT);
|
|
5338
|
-
const
|
|
5339
|
-
categories,
|
|
5340
|
-
opts.horizon
|
|
5341
|
-
);
|
|
5342
|
-
const stabilityForecast = this.computeStabilityForecast(
|
|
5343
|
-
categories,
|
|
5344
|
-
thresholds,
|
|
5345
|
-
snapshots
|
|
5346
|
-
);
|
|
5404
|
+
const adjustedCategories = categories;
|
|
5347
5405
|
return {
|
|
5348
5406
|
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5349
5407
|
snapshotsUsed: snapshots.length,
|
|
5350
|
-
timelineRange: {
|
|
5351
|
-
|
|
5352
|
-
|
|
5353
|
-
|
|
5354
|
-
stabilityForecast,
|
|
5355
|
-
categories,
|
|
5356
|
-
warnings
|
|
5408
|
+
timelineRange: { from: snapshots[0].capturedAt, to: lastSnapshot.capturedAt },
|
|
5409
|
+
stabilityForecast: this.computeStabilityForecast(adjustedCategories, thresholds, snapshots),
|
|
5410
|
+
categories: adjustedCategories,
|
|
5411
|
+
warnings: this.generateWarnings(adjustedCategories, opts.horizon)
|
|
5357
5412
|
};
|
|
5358
5413
|
}
|
|
5414
|
+
loadValidatedSnapshots() {
|
|
5415
|
+
const timeline = this.timelineManager.load();
|
|
5416
|
+
const snapshots = timeline.snapshots;
|
|
5417
|
+
if (snapshots.length < 3) {
|
|
5418
|
+
throw new Error(
|
|
5419
|
+
`PredictionEngine requires at least 3 snapshots, got ${snapshots.length}. Run "harness snapshot" to capture more data points.`
|
|
5420
|
+
);
|
|
5421
|
+
}
|
|
5422
|
+
return snapshots;
|
|
5423
|
+
}
|
|
5424
|
+
computeTimeOffsets(snapshots) {
|
|
5425
|
+
const firstDate = new Date(snapshots[0].capturedAt).getTime();
|
|
5426
|
+
const lastSnapshot = snapshots[snapshots.length - 1];
|
|
5427
|
+
const currentT = (new Date(lastSnapshot.capturedAt).getTime() - firstDate) / (7 * 24 * 60 * 60 * 1e3);
|
|
5428
|
+
return { firstDate, lastSnapshot, currentT };
|
|
5429
|
+
}
|
|
5359
5430
|
// --- Private helpers ---
|
|
5360
5431
|
resolveOptions(options) {
|
|
5361
5432
|
return {
|
|
@@ -5522,31 +5593,40 @@ var PredictionEngine = class {
|
|
|
5522
5593
|
for (const category of ALL_CATEGORIES2) {
|
|
5523
5594
|
const af = categories[category];
|
|
5524
5595
|
if (!af) continue;
|
|
5525
|
-
const
|
|
5526
|
-
|
|
5527
|
-
|
|
5528
|
-
|
|
5529
|
-
|
|
5530
|
-
|
|
5531
|
-
|
|
5532
|
-
|
|
5533
|
-
} else if (crossing <= horizon) {
|
|
5534
|
-
severity = "info";
|
|
5535
|
-
}
|
|
5536
|
-
if (severity) {
|
|
5537
|
-
const contributingNames = af.contributingFeatures.map((f) => f.name);
|
|
5538
|
-
warnings.push({
|
|
5539
|
-
severity,
|
|
5540
|
-
category,
|
|
5541
|
-
message: `${category} projected to exceed threshold (~${crossing}w, ${forecast.confidence} confidence)`,
|
|
5542
|
-
weeksUntil: crossing,
|
|
5543
|
-
confidence: forecast.confidence,
|
|
5544
|
-
contributingFeatures: contributingNames
|
|
5545
|
-
});
|
|
5546
|
-
}
|
|
5596
|
+
const warning = this.buildCategoryWarning(
|
|
5597
|
+
category,
|
|
5598
|
+
af,
|
|
5599
|
+
criticalWindow,
|
|
5600
|
+
warningWindow,
|
|
5601
|
+
horizon
|
|
5602
|
+
);
|
|
5603
|
+
if (warning) warnings.push(warning);
|
|
5547
5604
|
}
|
|
5548
5605
|
return warnings;
|
|
5549
5606
|
}
|
|
5607
|
+
buildCategoryWarning(category, af, criticalWindow, warningWindow, horizon) {
|
|
5608
|
+
const forecast = af.adjusted;
|
|
5609
|
+
const crossing = forecast.thresholdCrossingWeeks;
|
|
5610
|
+
if (crossing === null || crossing <= 0) return null;
|
|
5611
|
+
const isHighConfidence = forecast.confidence === "high" || forecast.confidence === "medium";
|
|
5612
|
+
let severity = null;
|
|
5613
|
+
if (crossing <= criticalWindow && isHighConfidence) {
|
|
5614
|
+
severity = "critical";
|
|
5615
|
+
} else if (crossing <= warningWindow && isHighConfidence) {
|
|
5616
|
+
severity = "warning";
|
|
5617
|
+
} else if (crossing <= horizon) {
|
|
5618
|
+
severity = "info";
|
|
5619
|
+
}
|
|
5620
|
+
if (!severity) return null;
|
|
5621
|
+
return {
|
|
5622
|
+
severity,
|
|
5623
|
+
category,
|
|
5624
|
+
message: `${category} projected to exceed threshold (~${crossing}w, ${forecast.confidence} confidence)`,
|
|
5625
|
+
weeksUntil: crossing,
|
|
5626
|
+
confidence: forecast.confidence,
|
|
5627
|
+
contributingFeatures: af.contributingFeatures.map((f) => f.name)
|
|
5628
|
+
};
|
|
5629
|
+
}
|
|
5550
5630
|
/**
|
|
5551
5631
|
* Compute composite stability forecast by projecting per-category values
|
|
5552
5632
|
* forward and computing stability scores at each horizon.
|
|
@@ -5615,14 +5695,9 @@ var PredictionEngine = class {
|
|
|
5615
5695
|
const raw = fs5.readFileSync(roadmapPath, "utf-8");
|
|
5616
5696
|
const parseResult = parseRoadmap(raw);
|
|
5617
5697
|
if (!parseResult.ok) return null;
|
|
5618
|
-
const features =
|
|
5619
|
-
|
|
5620
|
-
|
|
5621
|
-
if (feature.status === "planned" || feature.status === "in-progress") {
|
|
5622
|
-
features.push({ name: feature.name, spec: feature.spec });
|
|
5623
|
-
}
|
|
5624
|
-
}
|
|
5625
|
-
}
|
|
5698
|
+
const features = parseResult.value.milestones.flatMap(
|
|
5699
|
+
(m) => m.features.filter((f) => f.status === "planned" || f.status === "in-progress").map((f) => ({ name: f.name, spec: f.spec }))
|
|
5700
|
+
);
|
|
5626
5701
|
if (features.length === 0) return null;
|
|
5627
5702
|
return this.estimator.estimateAll(features);
|
|
5628
5703
|
} catch {
|
|
@@ -6273,7 +6348,7 @@ async function saveState(projectPath, state, stream, session) {
|
|
|
6273
6348
|
// src/state/learnings-content.ts
|
|
6274
6349
|
import * as fs11 from "fs";
|
|
6275
6350
|
import * as path8 from "path";
|
|
6276
|
-
import * as
|
|
6351
|
+
import * as crypto2 from "crypto";
|
|
6277
6352
|
function parseFrontmatter2(line) {
|
|
6278
6353
|
const match = line.match(/^<!--\s+hash:([a-f0-9]+)(?:\s+tags:([^\s]+))?\s+-->/);
|
|
6279
6354
|
if (!match) return null;
|
|
@@ -6301,7 +6376,7 @@ function extractIndexEntry(entry) {
|
|
|
6301
6376
|
};
|
|
6302
6377
|
}
|
|
6303
6378
|
function computeEntryHash(text) {
|
|
6304
|
-
return
|
|
6379
|
+
return crypto2.createHash("sha256").update(text).digest("hex").slice(0, 8);
|
|
6305
6380
|
}
|
|
6306
6381
|
function normalizeLearningContent(text) {
|
|
6307
6382
|
let normalized = text;
|
|
@@ -6316,7 +6391,7 @@ function normalizeLearningContent(text) {
|
|
|
6316
6391
|
return normalized;
|
|
6317
6392
|
}
|
|
6318
6393
|
function computeContentHash(text) {
|
|
6319
|
-
return
|
|
6394
|
+
return crypto2.createHash("sha256").update(text).digest("hex").slice(0, 16);
|
|
6320
6395
|
}
|
|
6321
6396
|
function loadContentHashes(stateDir) {
|
|
6322
6397
|
const hashesPath = path8.join(stateDir, CONTENT_HASHES_FILE);
|
|
@@ -6456,7 +6531,7 @@ async function loadRelevantLearnings(projectPath, skillName, stream, session) {
|
|
|
6456
6531
|
// src/state/learnings.ts
|
|
6457
6532
|
import * as fs13 from "fs";
|
|
6458
6533
|
import * as path10 from "path";
|
|
6459
|
-
import * as
|
|
6534
|
+
import * as crypto3 from "crypto";
|
|
6460
6535
|
async function appendLearning(projectPath, learning, skillName, outcome, stream, session) {
|
|
6461
6536
|
try {
|
|
6462
6537
|
const dirResult = await getStateDir(projectPath, stream, session);
|
|
@@ -6493,7 +6568,7 @@ async function appendLearning(projectPath, learning, skillName, outcome, stream,
|
|
|
6493
6568
|
} else {
|
|
6494
6569
|
bulletLine = `- **${timestamp}:** ${learning}`;
|
|
6495
6570
|
}
|
|
6496
|
-
const hash =
|
|
6571
|
+
const hash = crypto3.createHash("sha256").update(bulletLine).digest("hex").slice(0, 8);
|
|
6497
6572
|
const tagsStr = fmTags.length > 0 ? ` tags:${fmTags.join(",")}` : "";
|
|
6498
6573
|
const frontmatter = `<!-- hash:${hash}${tagsStr} -->`;
|
|
6499
6574
|
const entry = `
|
|
@@ -7204,7 +7279,7 @@ async function updateSessionEntryStatus(projectPath, sessionSlug, section, entry
|
|
|
7204
7279
|
}
|
|
7205
7280
|
function generateEntryId() {
|
|
7206
7281
|
const timestamp = Date.now().toString(36);
|
|
7207
|
-
const random =
|
|
7282
|
+
const random = Buffer.from(crypto.getRandomValues(new Uint8Array(4))).toString("hex");
|
|
7208
7283
|
return `${timestamp}-${random}`;
|
|
7209
7284
|
}
|
|
7210
7285
|
|
|
@@ -7349,15 +7424,25 @@ async function loadEvents(projectPath, options) {
|
|
|
7349
7424
|
);
|
|
7350
7425
|
}
|
|
7351
7426
|
}
|
|
7427
|
+
function phaseTransitionFields(data) {
|
|
7428
|
+
return {
|
|
7429
|
+
from: data?.from ?? "?",
|
|
7430
|
+
to: data?.to ?? "?",
|
|
7431
|
+
suffix: data?.taskCount ? ` (${data.taskCount} tasks)` : ""
|
|
7432
|
+
};
|
|
7433
|
+
}
|
|
7352
7434
|
function formatPhaseTransition(event) {
|
|
7353
7435
|
const data = event.data;
|
|
7354
|
-
const
|
|
7355
|
-
return `phase: ${
|
|
7436
|
+
const { from, to, suffix } = phaseTransitionFields(data);
|
|
7437
|
+
return `phase: ${from} -> ${to}${suffix}`;
|
|
7438
|
+
}
|
|
7439
|
+
function formatGateChecks(checks) {
|
|
7440
|
+
return checks?.map((c) => `${c.name} ${c.passed ? "Y" : "N"}`).join(", ");
|
|
7356
7441
|
}
|
|
7357
7442
|
function formatGateResult(event) {
|
|
7358
7443
|
const data = event.data;
|
|
7359
7444
|
const status = data?.passed ? "passed" : "failed";
|
|
7360
|
-
const checks = data?.checks
|
|
7445
|
+
const checks = formatGateChecks(data?.checks);
|
|
7361
7446
|
return checks ? `gate: ${status} (${checks})` : `gate: ${status}`;
|
|
7362
7447
|
}
|
|
7363
7448
|
function formatHandoffDetail(event) {
|
|
@@ -7633,34 +7718,36 @@ function resolveRuleSeverity(ruleId, defaultSeverity, overrides, strict) {
|
|
|
7633
7718
|
// src/security/stack-detector.ts
|
|
7634
7719
|
import * as fs22 from "fs";
|
|
7635
7720
|
import * as path19 from "path";
|
|
7636
|
-
function
|
|
7637
|
-
const
|
|
7721
|
+
function nodeSubStacks(allDeps) {
|
|
7722
|
+
const found = [];
|
|
7723
|
+
if (allDeps.react || allDeps["react-dom"]) found.push("react");
|
|
7724
|
+
if (allDeps.express) found.push("express");
|
|
7725
|
+
if (allDeps.koa) found.push("koa");
|
|
7726
|
+
if (allDeps.fastify) found.push("fastify");
|
|
7727
|
+
if (allDeps.next) found.push("next");
|
|
7728
|
+
if (allDeps.vue) found.push("vue");
|
|
7729
|
+
if (allDeps.angular || allDeps["@angular/core"]) found.push("angular");
|
|
7730
|
+
return found;
|
|
7731
|
+
}
|
|
7732
|
+
function detectNodeStacks(projectRoot) {
|
|
7638
7733
|
const pkgJsonPath = path19.join(projectRoot, "package.json");
|
|
7639
|
-
if (fs22.existsSync(pkgJsonPath))
|
|
7640
|
-
|
|
7641
|
-
|
|
7642
|
-
|
|
7643
|
-
|
|
7644
|
-
|
|
7645
|
-
|
|
7646
|
-
};
|
|
7647
|
-
if (allDeps.react || allDeps["react-dom"]) stacks.push("react");
|
|
7648
|
-
if (allDeps.express) stacks.push("express");
|
|
7649
|
-
if (allDeps.koa) stacks.push("koa");
|
|
7650
|
-
if (allDeps.fastify) stacks.push("fastify");
|
|
7651
|
-
if (allDeps.next) stacks.push("next");
|
|
7652
|
-
if (allDeps.vue) stacks.push("vue");
|
|
7653
|
-
if (allDeps.angular || allDeps["@angular/core"]) stacks.push("angular");
|
|
7654
|
-
} catch {
|
|
7655
|
-
}
|
|
7734
|
+
if (!fs22.existsSync(pkgJsonPath)) return [];
|
|
7735
|
+
const stacks = ["node"];
|
|
7736
|
+
try {
|
|
7737
|
+
const pkgJson = JSON.parse(fs22.readFileSync(pkgJsonPath, "utf-8"));
|
|
7738
|
+
const allDeps = { ...pkgJson.dependencies, ...pkgJson.devDependencies };
|
|
7739
|
+
stacks.push(...nodeSubStacks(allDeps));
|
|
7740
|
+
} catch {
|
|
7656
7741
|
}
|
|
7657
|
-
|
|
7658
|
-
|
|
7742
|
+
return stacks;
|
|
7743
|
+
}
|
|
7744
|
+
function detectStack(projectRoot) {
|
|
7745
|
+
const stacks = [...detectNodeStacks(projectRoot)];
|
|
7746
|
+
if (fs22.existsSync(path19.join(projectRoot, "go.mod"))) {
|
|
7659
7747
|
stacks.push("go");
|
|
7660
7748
|
}
|
|
7661
|
-
const
|
|
7662
|
-
|
|
7663
|
-
if (fs22.existsSync(requirementsPath) || fs22.existsSync(pyprojectPath)) {
|
|
7749
|
+
const hasPython = fs22.existsSync(path19.join(projectRoot, "requirements.txt")) || fs22.existsSync(path19.join(projectRoot, "pyproject.toml"));
|
|
7750
|
+
if (hasPython) {
|
|
7664
7751
|
stacks.push("python");
|
|
7665
7752
|
}
|
|
7666
7753
|
return stacks;
|
|
@@ -8504,6 +8591,56 @@ var SecurityScanner = class {
|
|
|
8504
8591
|
});
|
|
8505
8592
|
return this.scanLinesWithRules(lines, applicableRules, filePath, startLine);
|
|
8506
8593
|
}
|
|
8594
|
+
/** Build a finding for a suppression comment that is missing its justification. */
|
|
8595
|
+
buildSuppressionFinding(rule, filePath, lineNumber, line) {
|
|
8596
|
+
return {
|
|
8597
|
+
ruleId: rule.id,
|
|
8598
|
+
ruleName: rule.name,
|
|
8599
|
+
category: rule.category,
|
|
8600
|
+
severity: this.config.strict ? "error" : "warning",
|
|
8601
|
+
confidence: "high",
|
|
8602
|
+
file: filePath,
|
|
8603
|
+
line: lineNumber,
|
|
8604
|
+
match: line.trim(),
|
|
8605
|
+
context: line,
|
|
8606
|
+
message: `Suppression of ${rule.id} requires justification: // harness-ignore ${rule.id}: <reason>`,
|
|
8607
|
+
remediation: `Add justification after colon: // harness-ignore ${rule.id}: false positive because ...`
|
|
8608
|
+
};
|
|
8609
|
+
}
|
|
8610
|
+
/** Check one line against a rule's patterns; return a finding or null. */
|
|
8611
|
+
matchRuleLine(rule, resolved, filePath, lineNumber, line) {
|
|
8612
|
+
for (const pattern of rule.patterns) {
|
|
8613
|
+
pattern.lastIndex = 0;
|
|
8614
|
+
if (!pattern.test(line)) continue;
|
|
8615
|
+
return {
|
|
8616
|
+
ruleId: rule.id,
|
|
8617
|
+
ruleName: rule.name,
|
|
8618
|
+
category: rule.category,
|
|
8619
|
+
severity: resolved,
|
|
8620
|
+
confidence: rule.confidence,
|
|
8621
|
+
file: filePath,
|
|
8622
|
+
line: lineNumber,
|
|
8623
|
+
match: line.trim(),
|
|
8624
|
+
context: line,
|
|
8625
|
+
message: rule.message,
|
|
8626
|
+
remediation: rule.remediation,
|
|
8627
|
+
...rule.references ? { references: rule.references } : {}
|
|
8628
|
+
};
|
|
8629
|
+
}
|
|
8630
|
+
return null;
|
|
8631
|
+
}
|
|
8632
|
+
/** Scan a single line against a resolved rule; push any findings into the array. */
|
|
8633
|
+
scanLineForRule(rule, resolved, line, lineNumber, filePath, findings) {
|
|
8634
|
+
const suppressionMatch = parseHarnessIgnore(line, rule.id);
|
|
8635
|
+
if (suppressionMatch) {
|
|
8636
|
+
if (!suppressionMatch.justification) {
|
|
8637
|
+
findings.push(this.buildSuppressionFinding(rule, filePath, lineNumber, line));
|
|
8638
|
+
}
|
|
8639
|
+
return;
|
|
8640
|
+
}
|
|
8641
|
+
const finding = this.matchRuleLine(rule, resolved, filePath, lineNumber, line);
|
|
8642
|
+
if (finding) findings.push(finding);
|
|
8643
|
+
}
|
|
8507
8644
|
/**
|
|
8508
8645
|
* Core scanning loop shared by scanContent and scanContentForFile.
|
|
8509
8646
|
* Evaluates each rule against each line, handling suppression (FP gate)
|
|
@@ -8520,46 +8657,7 @@ var SecurityScanner = class {
|
|
|
8520
8657
|
);
|
|
8521
8658
|
if (resolved === "off") continue;
|
|
8522
8659
|
for (let i = 0; i < lines.length; i++) {
|
|
8523
|
-
|
|
8524
|
-
const suppressionMatch = parseHarnessIgnore(line, rule.id);
|
|
8525
|
-
if (suppressionMatch) {
|
|
8526
|
-
if (!suppressionMatch.justification) {
|
|
8527
|
-
findings.push({
|
|
8528
|
-
ruleId: rule.id,
|
|
8529
|
-
ruleName: rule.name,
|
|
8530
|
-
category: rule.category,
|
|
8531
|
-
severity: this.config.strict ? "error" : "warning",
|
|
8532
|
-
confidence: "high",
|
|
8533
|
-
file: filePath,
|
|
8534
|
-
line: startLine + i,
|
|
8535
|
-
match: line.trim(),
|
|
8536
|
-
context: line,
|
|
8537
|
-
message: `Suppression of ${rule.id} requires justification: // harness-ignore ${rule.id}: <reason>`,
|
|
8538
|
-
remediation: `Add justification after colon: // harness-ignore ${rule.id}: false positive because ...`
|
|
8539
|
-
});
|
|
8540
|
-
}
|
|
8541
|
-
continue;
|
|
8542
|
-
}
|
|
8543
|
-
for (const pattern of rule.patterns) {
|
|
8544
|
-
pattern.lastIndex = 0;
|
|
8545
|
-
if (pattern.test(line)) {
|
|
8546
|
-
findings.push({
|
|
8547
|
-
ruleId: rule.id,
|
|
8548
|
-
ruleName: rule.name,
|
|
8549
|
-
category: rule.category,
|
|
8550
|
-
severity: resolved,
|
|
8551
|
-
confidence: rule.confidence,
|
|
8552
|
-
file: filePath,
|
|
8553
|
-
line: startLine + i,
|
|
8554
|
-
match: line.trim(),
|
|
8555
|
-
context: line,
|
|
8556
|
-
message: rule.message,
|
|
8557
|
-
remediation: rule.remediation,
|
|
8558
|
-
...rule.references ? { references: rule.references } : {}
|
|
8559
|
-
});
|
|
8560
|
-
break;
|
|
8561
|
-
}
|
|
8562
|
-
}
|
|
8660
|
+
this.scanLineForRule(rule, resolved, lines[i] ?? "", startLine + i, filePath, findings);
|
|
8563
8661
|
}
|
|
8564
8662
|
}
|
|
8565
8663
|
return findings;
|
|
@@ -9979,37 +10077,30 @@ function extractConventionRules(bundle) {
|
|
|
9979
10077
|
}
|
|
9980
10078
|
return rules;
|
|
9981
10079
|
}
|
|
9982
|
-
|
|
10080
|
+
var EXPORT_RE = /export\s+(?:async\s+)?(?:function|const|class|interface|type)\s+(\w+)/;
|
|
10081
|
+
function hasPrecedingJsDoc(lines, i) {
|
|
10082
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
10083
|
+
const prev = lines[j].trim();
|
|
10084
|
+
if (prev === "") continue;
|
|
10085
|
+
return prev.endsWith("*/");
|
|
10086
|
+
}
|
|
10087
|
+
return false;
|
|
10088
|
+
}
|
|
10089
|
+
function scanFileForMissingJsDoc(filePath, lines) {
|
|
9983
10090
|
const missing = [];
|
|
9984
|
-
for (
|
|
9985
|
-
const
|
|
9986
|
-
|
|
9987
|
-
|
|
9988
|
-
const exportMatch = line.match(
|
|
9989
|
-
/export\s+(?:async\s+)?(?:function|const|class|interface|type)\s+(\w+)/
|
|
9990
|
-
);
|
|
9991
|
-
if (exportMatch) {
|
|
9992
|
-
let hasJsDoc = false;
|
|
9993
|
-
for (let j = i - 1; j >= 0; j--) {
|
|
9994
|
-
const prev = lines[j].trim();
|
|
9995
|
-
if (prev === "") continue;
|
|
9996
|
-
if (prev.endsWith("*/")) {
|
|
9997
|
-
hasJsDoc = true;
|
|
9998
|
-
}
|
|
9999
|
-
break;
|
|
10000
|
-
}
|
|
10001
|
-
if (!hasJsDoc) {
|
|
10002
|
-
missing.push({
|
|
10003
|
-
file: cf.path,
|
|
10004
|
-
line: i + 1,
|
|
10005
|
-
exportName: exportMatch[1]
|
|
10006
|
-
});
|
|
10007
|
-
}
|
|
10008
|
-
}
|
|
10091
|
+
for (let i = 0; i < lines.length; i++) {
|
|
10092
|
+
const exportMatch = lines[i].match(EXPORT_RE);
|
|
10093
|
+
if (exportMatch && !hasPrecedingJsDoc(lines, i)) {
|
|
10094
|
+
missing.push({ file: filePath, line: i + 1, exportName: exportMatch[1] });
|
|
10009
10095
|
}
|
|
10010
10096
|
}
|
|
10011
10097
|
return missing;
|
|
10012
10098
|
}
|
|
10099
|
+
function findMissingJsDoc(bundle) {
|
|
10100
|
+
return bundle.changedFiles.flatMap(
|
|
10101
|
+
(cf) => scanFileForMissingJsDoc(cf.path, cf.content.split("\n"))
|
|
10102
|
+
);
|
|
10103
|
+
}
|
|
10013
10104
|
function checkMissingJsDoc(bundle, rules) {
|
|
10014
10105
|
const jsDocRule = rules.find((r) => r.text.toLowerCase().includes("jsdoc"));
|
|
10015
10106
|
if (!jsDocRule) return [];
|
|
@@ -10074,29 +10165,27 @@ function checkChangeTypeSpecific(bundle) {
|
|
|
10074
10165
|
return [];
|
|
10075
10166
|
}
|
|
10076
10167
|
}
|
|
10168
|
+
function checkFileResultTypeConvention(cf, bundle, rule) {
|
|
10169
|
+
const hasTryCatch = cf.content.includes("try {") || cf.content.includes("try{");
|
|
10170
|
+
const usesResult = cf.content.includes("Result<") || cf.content.includes("Result >") || cf.content.includes(": Result");
|
|
10171
|
+
if (!hasTryCatch || usesResult) return null;
|
|
10172
|
+
return {
|
|
10173
|
+
id: makeFindingId("compliance", cf.path, 1, "try-catch not Result"),
|
|
10174
|
+
file: cf.path,
|
|
10175
|
+
lineRange: [1, cf.lines],
|
|
10176
|
+
domain: "compliance",
|
|
10177
|
+
severity: "suggestion",
|
|
10178
|
+
title: "Fallible operation uses try/catch instead of Result type",
|
|
10179
|
+
rationale: `Convention requires using Result type for fallible operations (from ${rule.source}).`,
|
|
10180
|
+
suggestion: "Refactor error handling to use the Result type pattern.",
|
|
10181
|
+
evidence: [`changeType: ${bundle.changeType}`, `Convention rule: "${rule.text}"`],
|
|
10182
|
+
validatedBy: "heuristic"
|
|
10183
|
+
};
|
|
10184
|
+
}
|
|
10077
10185
|
function checkResultTypeConvention(bundle, rules) {
|
|
10078
10186
|
const resultTypeRule = rules.find((r) => r.text.toLowerCase().includes("result type"));
|
|
10079
10187
|
if (!resultTypeRule) return [];
|
|
10080
|
-
|
|
10081
|
-
for (const cf of bundle.changedFiles) {
|
|
10082
|
-
const hasTryCatch = cf.content.includes("try {") || cf.content.includes("try{");
|
|
10083
|
-
const usesResult = cf.content.includes("Result<") || cf.content.includes("Result >") || cf.content.includes(": Result");
|
|
10084
|
-
if (hasTryCatch && !usesResult) {
|
|
10085
|
-
findings.push({
|
|
10086
|
-
id: makeFindingId("compliance", cf.path, 1, "try-catch not Result"),
|
|
10087
|
-
file: cf.path,
|
|
10088
|
-
lineRange: [1, cf.lines],
|
|
10089
|
-
domain: "compliance",
|
|
10090
|
-
severity: "suggestion",
|
|
10091
|
-
title: "Fallible operation uses try/catch instead of Result type",
|
|
10092
|
-
rationale: `Convention requires using Result type for fallible operations (from ${resultTypeRule.source}).`,
|
|
10093
|
-
suggestion: "Refactor error handling to use the Result type pattern.",
|
|
10094
|
-
evidence: [`changeType: ${bundle.changeType}`, `Convention rule: "${resultTypeRule.text}"`],
|
|
10095
|
-
validatedBy: "heuristic"
|
|
10096
|
-
});
|
|
10097
|
-
}
|
|
10098
|
-
}
|
|
10099
|
-
return findings;
|
|
10188
|
+
return bundle.changedFiles.map((cf) => checkFileResultTypeConvention(cf, bundle, resultTypeRule)).filter((f) => f !== null);
|
|
10100
10189
|
}
|
|
10101
10190
|
function runComplianceAgent(bundle) {
|
|
10102
10191
|
const rules = extractConventionRules(bundle);
|
|
@@ -10122,53 +10211,58 @@ var BUG_DETECTION_DESCRIPTOR = {
|
|
|
10122
10211
|
"Test coverage \u2014 tests for happy path, error paths, and edge cases"
|
|
10123
10212
|
]
|
|
10124
10213
|
};
|
|
10214
|
+
function hasPrecedingZeroCheck(lines, i) {
|
|
10215
|
+
const preceding = lines.slice(Math.max(0, i - 3), i).join("\n");
|
|
10216
|
+
return preceding.includes("=== 0") || preceding.includes("!== 0") || preceding.includes("== 0") || preceding.includes("!= 0");
|
|
10217
|
+
}
|
|
10125
10218
|
function detectDivisionByZero(bundle) {
|
|
10126
10219
|
const findings = [];
|
|
10127
10220
|
for (const cf of bundle.changedFiles) {
|
|
10128
10221
|
const lines = cf.content.split("\n");
|
|
10129
10222
|
for (let i = 0; i < lines.length; i++) {
|
|
10130
10223
|
const line = lines[i];
|
|
10131
|
-
if (line.match(/[^=!<>]\s*\/\s*[a-zA-Z_]\w*/)
|
|
10132
|
-
|
|
10133
|
-
|
|
10134
|
-
|
|
10135
|
-
|
|
10136
|
-
|
|
10137
|
-
|
|
10138
|
-
|
|
10139
|
-
|
|
10140
|
-
|
|
10141
|
-
|
|
10142
|
-
|
|
10143
|
-
|
|
10144
|
-
|
|
10145
|
-
});
|
|
10146
|
-
}
|
|
10147
|
-
}
|
|
10224
|
+
if (!line.match(/[^=!<>]\s*\/\s*[a-zA-Z_]\w*/) || line.includes("//")) continue;
|
|
10225
|
+
if (hasPrecedingZeroCheck(lines, i)) continue;
|
|
10226
|
+
findings.push({
|
|
10227
|
+
id: makeFindingId("bug", cf.path, i + 1, "division by zero"),
|
|
10228
|
+
file: cf.path,
|
|
10229
|
+
lineRange: [i + 1, i + 1],
|
|
10230
|
+
domain: "bug",
|
|
10231
|
+
severity: "important",
|
|
10232
|
+
title: "Potential division by zero without guard",
|
|
10233
|
+
rationale: "Division operation found without a preceding zero check on the divisor. This can cause Infinity or NaN at runtime.",
|
|
10234
|
+
suggestion: "Add a check for zero before dividing, or use a safe division utility.",
|
|
10235
|
+
evidence: [`Line ${i + 1}: ${line.trim()}`],
|
|
10236
|
+
validatedBy: "heuristic"
|
|
10237
|
+
});
|
|
10148
10238
|
}
|
|
10149
10239
|
}
|
|
10150
10240
|
return findings;
|
|
10151
10241
|
}
|
|
10242
|
+
function isEmptyCatch(lines, i) {
|
|
10243
|
+
const line = lines[i];
|
|
10244
|
+
if (line.match(/catch\s*\([^)]*\)\s*\{\s*\}/)) return true;
|
|
10245
|
+
return line.match(/catch\s*\([^)]*\)\s*\{/) !== null && i + 1 < lines.length && lines[i + 1].trim() === "}";
|
|
10246
|
+
}
|
|
10152
10247
|
function detectEmptyCatch(bundle) {
|
|
10153
10248
|
const findings = [];
|
|
10154
10249
|
for (const cf of bundle.changedFiles) {
|
|
10155
10250
|
const lines = cf.content.split("\n");
|
|
10156
10251
|
for (let i = 0; i < lines.length; i++) {
|
|
10252
|
+
if (!isEmptyCatch(lines, i)) continue;
|
|
10157
10253
|
const line = lines[i];
|
|
10158
|
-
|
|
10159
|
-
|
|
10160
|
-
|
|
10161
|
-
|
|
10162
|
-
|
|
10163
|
-
|
|
10164
|
-
|
|
10165
|
-
|
|
10166
|
-
|
|
10167
|
-
|
|
10168
|
-
|
|
10169
|
-
|
|
10170
|
-
});
|
|
10171
|
-
}
|
|
10254
|
+
findings.push({
|
|
10255
|
+
id: makeFindingId("bug", cf.path, i + 1, "empty catch block"),
|
|
10256
|
+
file: cf.path,
|
|
10257
|
+
lineRange: [i + 1, i + 2],
|
|
10258
|
+
domain: "bug",
|
|
10259
|
+
severity: "important",
|
|
10260
|
+
title: "Empty catch block silently swallows error",
|
|
10261
|
+
rationale: "Catching an error without handling, logging, or re-throwing it hides failures and makes debugging difficult.",
|
|
10262
|
+
suggestion: "Log the error, re-throw it, or handle it explicitly. If intentionally ignoring, add a comment explaining why.",
|
|
10263
|
+
evidence: [`Line ${i + 1}: ${line.trim()}`],
|
|
10264
|
+
validatedBy: "heuristic"
|
|
10265
|
+
});
|
|
10172
10266
|
}
|
|
10173
10267
|
}
|
|
10174
10268
|
return findings;
|
|
@@ -10226,34 +10320,102 @@ var SECRET_PATTERNS = [
|
|
|
10226
10320
|
];
|
|
10227
10321
|
var SQL_CONCAT_PATTERN = /(?:SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER)\s+.*?\+\s*\w+|`[^`]*\$\{[^}]*\}[^`]*(?:SELECT|INSERT|UPDATE|DELETE|WHERE)/i;
|
|
10228
10322
|
var SHELL_EXEC_PATTERN = /(?:exec|execSync|spawn|spawnSync)\s*\(\s*`[^`]*\$\{/;
|
|
10323
|
+
function makeEvalFinding(file, lineNum, line) {
|
|
10324
|
+
return {
|
|
10325
|
+
id: makeFindingId("security", file, lineNum, "eval usage CWE-94"),
|
|
10326
|
+
file,
|
|
10327
|
+
lineRange: [lineNum, lineNum],
|
|
10328
|
+
domain: "security",
|
|
10329
|
+
severity: "critical",
|
|
10330
|
+
title: `Dangerous ${"eval"}() or new ${"Function"}() usage`,
|
|
10331
|
+
rationale: `${"eval"}() and new ${"Function"}() execute arbitrary code. If user input reaches these calls, it enables Remote Code Execution (CWE-94).`,
|
|
10332
|
+
suggestion: "Replace eval/Function with a safe alternative (JSON.parse for data, a sandboxed evaluator for expressions).",
|
|
10333
|
+
evidence: [`Line ${lineNum}: ${line.trim()}`],
|
|
10334
|
+
validatedBy: "heuristic",
|
|
10335
|
+
cweId: "CWE-94",
|
|
10336
|
+
owaspCategory: "A03:2021 Injection",
|
|
10337
|
+
confidence: "high",
|
|
10338
|
+
remediation: "Replace eval/Function with a safe alternative (JSON.parse for data, a sandboxed evaluator for expressions).",
|
|
10339
|
+
references: [
|
|
10340
|
+
"https://cwe.mitre.org/data/definitions/94.html",
|
|
10341
|
+
"https://owasp.org/Top10/A03_2021-Injection/"
|
|
10342
|
+
]
|
|
10343
|
+
};
|
|
10344
|
+
}
|
|
10345
|
+
function makeSecretFinding(file, lineNum) {
|
|
10346
|
+
return {
|
|
10347
|
+
id: makeFindingId("security", file, lineNum, "hardcoded secret CWE-798"),
|
|
10348
|
+
file,
|
|
10349
|
+
lineRange: [lineNum, lineNum],
|
|
10350
|
+
domain: "security",
|
|
10351
|
+
severity: "critical",
|
|
10352
|
+
title: "Hardcoded secret or API key detected",
|
|
10353
|
+
rationale: "Hardcoded secrets in source code can be extracted from version history even after removal. Use environment variables or a secrets manager (CWE-798).",
|
|
10354
|
+
suggestion: "Move the secret to an environment variable and access it via process.env.",
|
|
10355
|
+
evidence: [`Line ${lineNum}: [secret detected \u2014 value redacted]`],
|
|
10356
|
+
validatedBy: "heuristic",
|
|
10357
|
+
cweId: "CWE-798",
|
|
10358
|
+
owaspCategory: "A07:2021 Identification and Authentication Failures",
|
|
10359
|
+
confidence: "high",
|
|
10360
|
+
remediation: "Move the secret to an environment variable and access it via process.env.",
|
|
10361
|
+
references: [
|
|
10362
|
+
"https://cwe.mitre.org/data/definitions/798.html",
|
|
10363
|
+
"https://owasp.org/Top10/A07_2021-Identification_and_Authentication_Failures/"
|
|
10364
|
+
]
|
|
10365
|
+
};
|
|
10366
|
+
}
|
|
10367
|
+
function makeSqlFinding(file, lineNum, line) {
|
|
10368
|
+
return {
|
|
10369
|
+
id: makeFindingId("security", file, lineNum, "SQL injection CWE-89"),
|
|
10370
|
+
file,
|
|
10371
|
+
lineRange: [lineNum, lineNum],
|
|
10372
|
+
domain: "security",
|
|
10373
|
+
severity: "critical",
|
|
10374
|
+
title: "Potential SQL injection via string concatenation",
|
|
10375
|
+
rationale: "Building SQL queries with string concatenation or template literals allows attackers to inject malicious SQL (CWE-89).",
|
|
10376
|
+
suggestion: "Use parameterized queries or a query builder (e.g., Knex, Prisma) instead of string concatenation.",
|
|
10377
|
+
evidence: [`Line ${lineNum}: ${line.trim()}`],
|
|
10378
|
+
validatedBy: "heuristic",
|
|
10379
|
+
cweId: "CWE-89",
|
|
10380
|
+
owaspCategory: "A03:2021 Injection",
|
|
10381
|
+
confidence: "high",
|
|
10382
|
+
remediation: "Use parameterized queries or a query builder (e.g., Knex, Prisma) instead of string concatenation.",
|
|
10383
|
+
references: [
|
|
10384
|
+
"https://cwe.mitre.org/data/definitions/89.html",
|
|
10385
|
+
"https://owasp.org/Top10/A03_2021-Injection/"
|
|
10386
|
+
]
|
|
10387
|
+
};
|
|
10388
|
+
}
|
|
10389
|
+
function makeCommandFinding(file, lineNum, line) {
|
|
10390
|
+
return {
|
|
10391
|
+
id: makeFindingId("security", file, lineNum, "command injection CWE-78"),
|
|
10392
|
+
file,
|
|
10393
|
+
lineRange: [lineNum, lineNum],
|
|
10394
|
+
domain: "security",
|
|
10395
|
+
severity: "critical",
|
|
10396
|
+
title: "Potential command injection via shell exec with interpolation",
|
|
10397
|
+
rationale: "Using exec/spawn with template literal interpolation allows attackers to inject shell commands (CWE-78).",
|
|
10398
|
+
suggestion: "Use execFile or spawn with an arguments array instead of shell string interpolation.",
|
|
10399
|
+
evidence: [`Line ${lineNum}: ${line.trim()}`],
|
|
10400
|
+
validatedBy: "heuristic",
|
|
10401
|
+
cweId: "CWE-78",
|
|
10402
|
+
owaspCategory: "A03:2021 Injection",
|
|
10403
|
+
confidence: "high",
|
|
10404
|
+
remediation: "Use execFile or spawn with an arguments array instead of shell string interpolation.",
|
|
10405
|
+
references: [
|
|
10406
|
+
"https://cwe.mitre.org/data/definitions/78.html",
|
|
10407
|
+
"https://owasp.org/Top10/A03_2021-Injection/"
|
|
10408
|
+
]
|
|
10409
|
+
};
|
|
10410
|
+
}
|
|
10229
10411
|
function detectEvalUsage(bundle) {
|
|
10230
10412
|
const findings = [];
|
|
10231
10413
|
for (const cf of bundle.changedFiles) {
|
|
10232
10414
|
const lines = cf.content.split("\n");
|
|
10233
10415
|
for (let i = 0; i < lines.length; i++) {
|
|
10234
10416
|
const line = lines[i];
|
|
10235
|
-
if (EVAL_PATTERN.test(line))
|
|
10236
|
-
|
|
10237
|
-
id: makeFindingId("security", cf.path, i + 1, "eval usage CWE-94"),
|
|
10238
|
-
file: cf.path,
|
|
10239
|
-
lineRange: [i + 1, i + 1],
|
|
10240
|
-
domain: "security",
|
|
10241
|
-
severity: "critical",
|
|
10242
|
-
title: `Dangerous ${"eval"}() or new ${"Function"}() usage`,
|
|
10243
|
-
rationale: `${"eval"}() and new ${"Function"}() execute arbitrary code. If user input reaches these calls, it enables Remote Code Execution (CWE-94).`,
|
|
10244
|
-
suggestion: "Replace eval/Function with a safe alternative (JSON.parse for data, a sandboxed evaluator for expressions).",
|
|
10245
|
-
evidence: [`Line ${i + 1}: ${line.trim()}`],
|
|
10246
|
-
validatedBy: "heuristic",
|
|
10247
|
-
cweId: "CWE-94",
|
|
10248
|
-
owaspCategory: "A03:2021 Injection",
|
|
10249
|
-
confidence: "high",
|
|
10250
|
-
remediation: "Replace eval/Function with a safe alternative (JSON.parse for data, a sandboxed evaluator for expressions).",
|
|
10251
|
-
references: [
|
|
10252
|
-
"https://cwe.mitre.org/data/definitions/94.html",
|
|
10253
|
-
"https://owasp.org/Top10/A03_2021-Injection/"
|
|
10254
|
-
]
|
|
10255
|
-
});
|
|
10256
|
-
}
|
|
10417
|
+
if (!EVAL_PATTERN.test(line)) continue;
|
|
10418
|
+
findings.push(makeEvalFinding(cf.path, i + 1, line));
|
|
10257
10419
|
}
|
|
10258
10420
|
}
|
|
10259
10421
|
return findings;
|
|
@@ -10265,31 +10427,9 @@ function detectHardcodedSecrets(bundle) {
|
|
|
10265
10427
|
for (let i = 0; i < lines.length; i++) {
|
|
10266
10428
|
const line = lines[i];
|
|
10267
10429
|
const codePart = line.includes("//") ? line.slice(0, line.indexOf("//")) : line;
|
|
10268
|
-
|
|
10269
|
-
|
|
10270
|
-
|
|
10271
|
-
id: makeFindingId("security", cf.path, i + 1, "hardcoded secret CWE-798"),
|
|
10272
|
-
file: cf.path,
|
|
10273
|
-
lineRange: [i + 1, i + 1],
|
|
10274
|
-
domain: "security",
|
|
10275
|
-
severity: "critical",
|
|
10276
|
-
title: "Hardcoded secret or API key detected",
|
|
10277
|
-
rationale: "Hardcoded secrets in source code can be extracted from version history even after removal. Use environment variables or a secrets manager (CWE-798).",
|
|
10278
|
-
suggestion: "Move the secret to an environment variable and access it via process.env.",
|
|
10279
|
-
evidence: [`Line ${i + 1}: [secret detected \u2014 value redacted]`],
|
|
10280
|
-
validatedBy: "heuristic",
|
|
10281
|
-
cweId: "CWE-798",
|
|
10282
|
-
owaspCategory: "A07:2021 Identification and Authentication Failures",
|
|
10283
|
-
confidence: "high",
|
|
10284
|
-
remediation: "Move the secret to an environment variable and access it via process.env.",
|
|
10285
|
-
references: [
|
|
10286
|
-
"https://cwe.mitre.org/data/definitions/798.html",
|
|
10287
|
-
"https://owasp.org/Top10/A07_2021-Identification_and_Authentication_Failures/"
|
|
10288
|
-
]
|
|
10289
|
-
});
|
|
10290
|
-
break;
|
|
10291
|
-
}
|
|
10292
|
-
}
|
|
10430
|
+
const matched = SECRET_PATTERNS.some((p) => p.test(codePart));
|
|
10431
|
+
if (!matched) continue;
|
|
10432
|
+
findings.push(makeSecretFinding(cf.path, i + 1));
|
|
10293
10433
|
}
|
|
10294
10434
|
}
|
|
10295
10435
|
return findings;
|
|
@@ -10300,28 +10440,8 @@ function detectSqlInjection(bundle) {
|
|
|
10300
10440
|
const lines = cf.content.split("\n");
|
|
10301
10441
|
for (let i = 0; i < lines.length; i++) {
|
|
10302
10442
|
const line = lines[i];
|
|
10303
|
-
if (SQL_CONCAT_PATTERN.test(line))
|
|
10304
|
-
|
|
10305
|
-
id: makeFindingId("security", cf.path, i + 1, "SQL injection CWE-89"),
|
|
10306
|
-
file: cf.path,
|
|
10307
|
-
lineRange: [i + 1, i + 1],
|
|
10308
|
-
domain: "security",
|
|
10309
|
-
severity: "critical",
|
|
10310
|
-
title: "Potential SQL injection via string concatenation",
|
|
10311
|
-
rationale: "Building SQL queries with string concatenation or template literals allows attackers to inject malicious SQL (CWE-89).",
|
|
10312
|
-
suggestion: "Use parameterized queries or a query builder (e.g., Knex, Prisma) instead of string concatenation.",
|
|
10313
|
-
evidence: [`Line ${i + 1}: ${line.trim()}`],
|
|
10314
|
-
validatedBy: "heuristic",
|
|
10315
|
-
cweId: "CWE-89",
|
|
10316
|
-
owaspCategory: "A03:2021 Injection",
|
|
10317
|
-
confidence: "high",
|
|
10318
|
-
remediation: "Use parameterized queries or a query builder (e.g., Knex, Prisma) instead of string concatenation.",
|
|
10319
|
-
references: [
|
|
10320
|
-
"https://cwe.mitre.org/data/definitions/89.html",
|
|
10321
|
-
"https://owasp.org/Top10/A03_2021-Injection/"
|
|
10322
|
-
]
|
|
10323
|
-
});
|
|
10324
|
-
}
|
|
10443
|
+
if (!SQL_CONCAT_PATTERN.test(line)) continue;
|
|
10444
|
+
findings.push(makeSqlFinding(cf.path, i + 1, line));
|
|
10325
10445
|
}
|
|
10326
10446
|
}
|
|
10327
10447
|
return findings;
|
|
@@ -10332,28 +10452,8 @@ function detectCommandInjection(bundle) {
|
|
|
10332
10452
|
const lines = cf.content.split("\n");
|
|
10333
10453
|
for (let i = 0; i < lines.length; i++) {
|
|
10334
10454
|
const line = lines[i];
|
|
10335
|
-
if (SHELL_EXEC_PATTERN.test(line))
|
|
10336
|
-
|
|
10337
|
-
id: makeFindingId("security", cf.path, i + 1, "command injection CWE-78"),
|
|
10338
|
-
file: cf.path,
|
|
10339
|
-
lineRange: [i + 1, i + 1],
|
|
10340
|
-
domain: "security",
|
|
10341
|
-
severity: "critical",
|
|
10342
|
-
title: "Potential command injection via shell exec with interpolation",
|
|
10343
|
-
rationale: "Using exec/spawn with template literal interpolation allows attackers to inject shell commands (CWE-78).",
|
|
10344
|
-
suggestion: "Use execFile or spawn with an arguments array instead of shell string interpolation.",
|
|
10345
|
-
evidence: [`Line ${i + 1}: ${line.trim()}`],
|
|
10346
|
-
validatedBy: "heuristic",
|
|
10347
|
-
cweId: "CWE-78",
|
|
10348
|
-
owaspCategory: "A03:2021 Injection",
|
|
10349
|
-
confidence: "high",
|
|
10350
|
-
remediation: "Use execFile or spawn with an arguments array instead of shell string interpolation.",
|
|
10351
|
-
references: [
|
|
10352
|
-
"https://cwe.mitre.org/data/definitions/78.html",
|
|
10353
|
-
"https://owasp.org/Top10/A03_2021-Injection/"
|
|
10354
|
-
]
|
|
10355
|
-
});
|
|
10356
|
-
}
|
|
10455
|
+
if (!SHELL_EXEC_PATTERN.test(line)) continue;
|
|
10456
|
+
findings.push(makeCommandFinding(cf.path, i + 1, line));
|
|
10357
10457
|
}
|
|
10358
10458
|
}
|
|
10359
10459
|
return findings;
|
|
@@ -10386,10 +10486,15 @@ function isViolationLine(line) {
|
|
|
10386
10486
|
const lower = line.toLowerCase();
|
|
10387
10487
|
return lower.includes("violation") || lower.includes("layer");
|
|
10388
10488
|
}
|
|
10389
|
-
|
|
10390
|
-
|
|
10489
|
+
var VIOLATION_FILE_RE = /(?:in\s+)?(\S+\.(?:ts|tsx|js|jsx))(?::(\d+))?/;
|
|
10490
|
+
function extractViolationLocation(line, fallbackPath) {
|
|
10491
|
+
const fileMatch = line.match(VIOLATION_FILE_RE);
|
|
10391
10492
|
const file = fileMatch?.[1] ?? fallbackPath;
|
|
10392
10493
|
const lineNum = fileMatch?.[2] ? parseInt(fileMatch[2], 10) : 1;
|
|
10494
|
+
return { file, lineNum };
|
|
10495
|
+
}
|
|
10496
|
+
function createLayerViolationFinding(line, fallbackPath) {
|
|
10497
|
+
const { file, lineNum } = extractViolationLocation(line, fallbackPath);
|
|
10393
10498
|
return {
|
|
10394
10499
|
id: makeFindingId("arch", file, lineNum, "layer violation"),
|
|
10395
10500
|
file,
|
|
@@ -10562,6 +10667,26 @@ function normalizePath(filePath, projectRoot) {
|
|
|
10562
10667
|
}
|
|
10563
10668
|
return normalized;
|
|
10564
10669
|
}
|
|
10670
|
+
function resolveImportPath2(currentFile, importPath) {
|
|
10671
|
+
const dir = path23.dirname(currentFile);
|
|
10672
|
+
let resolved = path23.join(dir, importPath).replace(/\\/g, "/");
|
|
10673
|
+
if (!resolved.match(/\.(ts|tsx|js|jsx)$/)) {
|
|
10674
|
+
resolved += ".ts";
|
|
10675
|
+
}
|
|
10676
|
+
return path23.normalize(resolved).replace(/\\/g, "/");
|
|
10677
|
+
}
|
|
10678
|
+
function enqueueImports(content, current, visited, queue, maxDepth) {
|
|
10679
|
+
const importRegex = /import\s+.*?from\s+['"]([^'"]+)['"]/g;
|
|
10680
|
+
let match;
|
|
10681
|
+
while ((match = importRegex.exec(content)) !== null) {
|
|
10682
|
+
const importPath = match[1];
|
|
10683
|
+
if (!importPath.startsWith(".")) continue;
|
|
10684
|
+
const resolved = resolveImportPath2(current.file, importPath);
|
|
10685
|
+
if (!visited.has(resolved) && current.depth + 1 <= maxDepth) {
|
|
10686
|
+
queue.push({ file: resolved, depth: current.depth + 1 });
|
|
10687
|
+
}
|
|
10688
|
+
}
|
|
10689
|
+
}
|
|
10565
10690
|
function followImportChain(fromFile, fileContents, maxDepth = 2) {
|
|
10566
10691
|
const visited = /* @__PURE__ */ new Set();
|
|
10567
10692
|
const queue = [{ file: fromFile, depth: 0 }];
|
|
@@ -10571,82 +10696,63 @@ function followImportChain(fromFile, fileContents, maxDepth = 2) {
|
|
|
10571
10696
|
visited.add(current.file);
|
|
10572
10697
|
const content = fileContents.get(current.file);
|
|
10573
10698
|
if (!content) continue;
|
|
10574
|
-
|
|
10575
|
-
let match;
|
|
10576
|
-
while ((match = importRegex.exec(content)) !== null) {
|
|
10577
|
-
const importPath = match[1];
|
|
10578
|
-
if (!importPath.startsWith(".")) continue;
|
|
10579
|
-
const dir = path23.dirname(current.file);
|
|
10580
|
-
let resolved = path23.join(dir, importPath).replace(/\\/g, "/");
|
|
10581
|
-
if (!resolved.match(/\.(ts|tsx|js|jsx)$/)) {
|
|
10582
|
-
resolved += ".ts";
|
|
10583
|
-
}
|
|
10584
|
-
resolved = path23.normalize(resolved).replace(/\\/g, "/");
|
|
10585
|
-
if (!visited.has(resolved) && current.depth + 1 <= maxDepth) {
|
|
10586
|
-
queue.push({ file: resolved, depth: current.depth + 1 });
|
|
10587
|
-
}
|
|
10588
|
-
}
|
|
10699
|
+
enqueueImports(content, current, visited, queue, maxDepth);
|
|
10589
10700
|
}
|
|
10590
10701
|
visited.delete(fromFile);
|
|
10591
10702
|
return visited;
|
|
10592
10703
|
}
|
|
10704
|
+
function isMechanicallyExcluded(finding, exclusionSet, projectRoot) {
|
|
10705
|
+
const normalizedFile = normalizePath(finding.file, projectRoot);
|
|
10706
|
+
if (exclusionSet.isExcluded(normalizedFile, finding.lineRange)) return true;
|
|
10707
|
+
if (exclusionSet.isExcluded(finding.file, finding.lineRange)) return true;
|
|
10708
|
+
const absoluteFile = path23.isAbsolute(finding.file) ? finding.file : path23.join(projectRoot, finding.file).replace(/\\/g, "/");
|
|
10709
|
+
return exclusionSet.isExcluded(absoluteFile, finding.lineRange);
|
|
10710
|
+
}
|
|
10711
|
+
async function validateWithGraph(crossFileRefs, graph) {
|
|
10712
|
+
try {
|
|
10713
|
+
for (const ref of crossFileRefs) {
|
|
10714
|
+
const reachable = await graph.isReachable(ref.from, ref.to);
|
|
10715
|
+
if (!reachable) return { result: "discard" };
|
|
10716
|
+
}
|
|
10717
|
+
return { result: "keep" };
|
|
10718
|
+
} catch {
|
|
10719
|
+
return { result: "fallback" };
|
|
10720
|
+
}
|
|
10721
|
+
}
|
|
10722
|
+
function validateWithHeuristic(finding, crossFileRefs, fileContents, projectRoot) {
|
|
10723
|
+
if (fileContents) {
|
|
10724
|
+
for (const ref of crossFileRefs) {
|
|
10725
|
+
const normalizedFrom = normalizePath(ref.from, projectRoot);
|
|
10726
|
+
const reachable = followImportChain(normalizedFrom, fileContents, 2);
|
|
10727
|
+
const normalizedTo = normalizePath(ref.to, projectRoot);
|
|
10728
|
+
if (reachable.has(normalizedTo)) {
|
|
10729
|
+
return { ...finding, validatedBy: "heuristic" };
|
|
10730
|
+
}
|
|
10731
|
+
}
|
|
10732
|
+
}
|
|
10733
|
+
return {
|
|
10734
|
+
...finding,
|
|
10735
|
+
severity: DOWNGRADE_MAP[finding.severity],
|
|
10736
|
+
validatedBy: "heuristic"
|
|
10737
|
+
};
|
|
10738
|
+
}
|
|
10739
|
+
async function processFinding(finding, exclusionSet, graph, projectRoot, fileContents) {
|
|
10740
|
+
if (isMechanicallyExcluded(finding, exclusionSet, projectRoot)) return null;
|
|
10741
|
+
const crossFileRefs = extractCrossFileRefs(finding);
|
|
10742
|
+
if (crossFileRefs.length === 0) return { ...finding };
|
|
10743
|
+
if (graph) {
|
|
10744
|
+
const { result } = await validateWithGraph(crossFileRefs, graph);
|
|
10745
|
+
if (result === "keep") return { ...finding, validatedBy: "graph" };
|
|
10746
|
+
if (result === "discard") return null;
|
|
10747
|
+
}
|
|
10748
|
+
return validateWithHeuristic(finding, crossFileRefs, fileContents, projectRoot);
|
|
10749
|
+
}
|
|
10593
10750
|
async function validateFindings(options) {
|
|
10594
10751
|
const { findings, exclusionSet, graph, projectRoot, fileContents } = options;
|
|
10595
10752
|
const validated = [];
|
|
10596
10753
|
for (const finding of findings) {
|
|
10597
|
-
const
|
|
10598
|
-
if (
|
|
10599
|
-
continue;
|
|
10600
|
-
}
|
|
10601
|
-
const absoluteFile = path23.isAbsolute(finding.file) ? finding.file : path23.join(projectRoot, finding.file).replace(/\\/g, "/");
|
|
10602
|
-
if (exclusionSet.isExcluded(absoluteFile, finding.lineRange)) {
|
|
10603
|
-
continue;
|
|
10604
|
-
}
|
|
10605
|
-
const crossFileRefs = extractCrossFileRefs(finding);
|
|
10606
|
-
if (crossFileRefs.length === 0) {
|
|
10607
|
-
validated.push({ ...finding });
|
|
10608
|
-
continue;
|
|
10609
|
-
}
|
|
10610
|
-
if (graph) {
|
|
10611
|
-
try {
|
|
10612
|
-
let allReachable = true;
|
|
10613
|
-
for (const ref of crossFileRefs) {
|
|
10614
|
-
const reachable = await graph.isReachable(ref.from, ref.to);
|
|
10615
|
-
if (!reachable) {
|
|
10616
|
-
allReachable = false;
|
|
10617
|
-
break;
|
|
10618
|
-
}
|
|
10619
|
-
}
|
|
10620
|
-
if (allReachable) {
|
|
10621
|
-
validated.push({ ...finding, validatedBy: "graph" });
|
|
10622
|
-
}
|
|
10623
|
-
continue;
|
|
10624
|
-
} catch {
|
|
10625
|
-
}
|
|
10626
|
-
}
|
|
10627
|
-
{
|
|
10628
|
-
let chainValidated = false;
|
|
10629
|
-
if (fileContents) {
|
|
10630
|
-
for (const ref of crossFileRefs) {
|
|
10631
|
-
const normalizedFrom = normalizePath(ref.from, projectRoot);
|
|
10632
|
-
const reachable = followImportChain(normalizedFrom, fileContents, 2);
|
|
10633
|
-
const normalizedTo = normalizePath(ref.to, projectRoot);
|
|
10634
|
-
if (reachable.has(normalizedTo)) {
|
|
10635
|
-
chainValidated = true;
|
|
10636
|
-
break;
|
|
10637
|
-
}
|
|
10638
|
-
}
|
|
10639
|
-
}
|
|
10640
|
-
if (chainValidated) {
|
|
10641
|
-
validated.push({ ...finding, validatedBy: "heuristic" });
|
|
10642
|
-
} else {
|
|
10643
|
-
validated.push({
|
|
10644
|
-
...finding,
|
|
10645
|
-
severity: DOWNGRADE_MAP[finding.severity],
|
|
10646
|
-
validatedBy: "heuristic"
|
|
10647
|
-
});
|
|
10648
|
-
}
|
|
10649
|
-
}
|
|
10754
|
+
const result = await processFinding(finding, exclusionSet, graph, projectRoot, fileContents);
|
|
10755
|
+
if (result !== null) validated.push(result);
|
|
10650
10756
|
}
|
|
10651
10757
|
return validated;
|
|
10652
10758
|
}
|
|
@@ -11247,25 +11353,32 @@ function serializeRoadmap(roadmap) {
|
|
|
11247
11353
|
function serializeMilestoneHeading(milestone) {
|
|
11248
11354
|
return milestone.isBacklog ? "## Backlog" : `## ${milestone.name}`;
|
|
11249
11355
|
}
|
|
11356
|
+
function orDash(value) {
|
|
11357
|
+
return value ?? EM_DASH2;
|
|
11358
|
+
}
|
|
11359
|
+
function listOrDash(items) {
|
|
11360
|
+
return items.length > 0 ? items.join(", ") : EM_DASH2;
|
|
11361
|
+
}
|
|
11362
|
+
function serializeExtendedLines(feature) {
|
|
11363
|
+
const hasExtended = feature.assignee !== null || feature.priority !== null || feature.externalId !== null;
|
|
11364
|
+
if (!hasExtended) return [];
|
|
11365
|
+
return [
|
|
11366
|
+
`- **Assignee:** ${orDash(feature.assignee)}`,
|
|
11367
|
+
`- **Priority:** ${orDash(feature.priority)}`,
|
|
11368
|
+
`- **External-ID:** ${orDash(feature.externalId)}`
|
|
11369
|
+
];
|
|
11370
|
+
}
|
|
11250
11371
|
function serializeFeature(feature) {
|
|
11251
|
-
const spec = feature.spec ?? EM_DASH2;
|
|
11252
|
-
const plans = feature.plans.length > 0 ? feature.plans.join(", ") : EM_DASH2;
|
|
11253
|
-
const blockedBy = feature.blockedBy.length > 0 ? feature.blockedBy.join(", ") : EM_DASH2;
|
|
11254
11372
|
const lines = [
|
|
11255
11373
|
`### ${feature.name}`,
|
|
11256
11374
|
"",
|
|
11257
11375
|
`- **Status:** ${feature.status}`,
|
|
11258
|
-
`- **Spec:** ${spec}`,
|
|
11376
|
+
`- **Spec:** ${orDash(feature.spec)}`,
|
|
11259
11377
|
`- **Summary:** ${feature.summary}`,
|
|
11260
|
-
`- **Blockers:** ${blockedBy}`,
|
|
11261
|
-
`- **Plan:** ${plans}
|
|
11378
|
+
`- **Blockers:** ${listOrDash(feature.blockedBy)}`,
|
|
11379
|
+
`- **Plan:** ${listOrDash(feature.plans)}`,
|
|
11380
|
+
...serializeExtendedLines(feature)
|
|
11262
11381
|
];
|
|
11263
|
-
const hasExtended = feature.assignee !== null || feature.priority !== null || feature.externalId !== null;
|
|
11264
|
-
if (hasExtended) {
|
|
11265
|
-
lines.push(`- **Assignee:** ${feature.assignee ?? EM_DASH2}`);
|
|
11266
|
-
lines.push(`- **Priority:** ${feature.priority ?? EM_DASH2}`);
|
|
11267
|
-
lines.push(`- **External-ID:** ${feature.externalId ?? EM_DASH2}`);
|
|
11268
|
-
}
|
|
11269
11382
|
return lines;
|
|
11270
11383
|
}
|
|
11271
11384
|
function serializeAssignmentHistory(records) {
|
|
@@ -11299,6 +11412,26 @@ function isRegression(from, to) {
|
|
|
11299
11412
|
}
|
|
11300
11413
|
|
|
11301
11414
|
// src/roadmap/sync.ts
|
|
11415
|
+
function collectAutopilotStatuses(autopilotPath, featurePlans, allTaskStatuses) {
|
|
11416
|
+
try {
|
|
11417
|
+
const raw = fs24.readFileSync(autopilotPath, "utf-8");
|
|
11418
|
+
const autopilot = JSON.parse(raw);
|
|
11419
|
+
if (!autopilot.phases) return;
|
|
11420
|
+
const linkedPhases = autopilot.phases.filter(
|
|
11421
|
+
(phase) => phase.planPath ? featurePlans.some((p) => p === phase.planPath || phase.planPath.endsWith(p)) : false
|
|
11422
|
+
);
|
|
11423
|
+
for (const phase of linkedPhases) {
|
|
11424
|
+
if (phase.status === "complete") {
|
|
11425
|
+
allTaskStatuses.push("complete");
|
|
11426
|
+
} else if (phase.status === "pending") {
|
|
11427
|
+
allTaskStatuses.push("pending");
|
|
11428
|
+
} else {
|
|
11429
|
+
allTaskStatuses.push("in_progress");
|
|
11430
|
+
}
|
|
11431
|
+
}
|
|
11432
|
+
} catch {
|
|
11433
|
+
}
|
|
11434
|
+
}
|
|
11302
11435
|
function inferStatus(feature, projectPath, allFeatures) {
|
|
11303
11436
|
if (feature.blockedBy.length > 0) {
|
|
11304
11437
|
const blockerNotDone = feature.blockedBy.some((blockerName) => {
|
|
@@ -11334,26 +11467,7 @@ function inferStatus(feature, projectPath, allFeatures) {
|
|
|
11334
11467
|
if (!entry.isDirectory()) continue;
|
|
11335
11468
|
const autopilotPath = path24.join(sessionsDir, entry.name, "autopilot-state.json");
|
|
11336
11469
|
if (!fs24.existsSync(autopilotPath)) continue;
|
|
11337
|
-
|
|
11338
|
-
const raw = fs24.readFileSync(autopilotPath, "utf-8");
|
|
11339
|
-
const autopilot = JSON.parse(raw);
|
|
11340
|
-
if (!autopilot.phases) continue;
|
|
11341
|
-
const linkedPhases = autopilot.phases.filter(
|
|
11342
|
-
(phase) => phase.planPath ? feature.plans.some((p) => p === phase.planPath || phase.planPath.endsWith(p)) : false
|
|
11343
|
-
);
|
|
11344
|
-
if (linkedPhases.length > 0) {
|
|
11345
|
-
for (const phase of linkedPhases) {
|
|
11346
|
-
if (phase.status === "complete") {
|
|
11347
|
-
allTaskStatuses.push("complete");
|
|
11348
|
-
} else if (phase.status === "pending") {
|
|
11349
|
-
allTaskStatuses.push("pending");
|
|
11350
|
-
} else {
|
|
11351
|
-
allTaskStatuses.push("in_progress");
|
|
11352
|
-
}
|
|
11353
|
-
}
|
|
11354
|
-
}
|
|
11355
|
-
} catch {
|
|
11356
|
-
}
|
|
11470
|
+
collectAutopilotStatuses(autopilotPath, feature.plans, allTaskStatuses);
|
|
11357
11471
|
}
|
|
11358
11472
|
} catch {
|
|
11359
11473
|
}
|
|
@@ -11670,23 +11784,36 @@ var GitHubIssuesSyncAdapter = class {
|
|
|
11670
11784
|
return Err3(error instanceof Error ? error : new Error(String(error)));
|
|
11671
11785
|
}
|
|
11672
11786
|
}
|
|
11787
|
+
buildLabelsParam() {
|
|
11788
|
+
const filterLabels = this.config.labels ?? [];
|
|
11789
|
+
return filterLabels.length > 0 ? `&labels=${filterLabels.join(",")}` : "";
|
|
11790
|
+
}
|
|
11791
|
+
issueToTicketState(issue) {
|
|
11792
|
+
return {
|
|
11793
|
+
externalId: buildExternalId(this.owner, this.repo, issue.number),
|
|
11794
|
+
title: issue.title,
|
|
11795
|
+
status: issue.state,
|
|
11796
|
+
labels: issue.labels.map((l) => l.name),
|
|
11797
|
+
assignee: issue.assignee ? `@${issue.assignee.login}` : null
|
|
11798
|
+
};
|
|
11799
|
+
}
|
|
11800
|
+
async fetchIssuePage(page, labelsParam) {
|
|
11801
|
+
const perPage = 100;
|
|
11802
|
+
return fetchWithRetry(
|
|
11803
|
+
this.fetchFn,
|
|
11804
|
+
`${this.apiBase}/repos/${this.owner}/${this.repo}/issues?state=all&per_page=${perPage}&page=${page}${labelsParam}`,
|
|
11805
|
+
{ method: "GET", headers: this.headers() },
|
|
11806
|
+
this.retryOpts
|
|
11807
|
+
);
|
|
11808
|
+
}
|
|
11673
11809
|
async fetchAllTickets() {
|
|
11674
11810
|
try {
|
|
11675
|
-
const
|
|
11676
|
-
const labelsParam = filterLabels.length > 0 ? `&labels=${filterLabels.join(",")}` : "";
|
|
11811
|
+
const labelsParam = this.buildLabelsParam();
|
|
11677
11812
|
const tickets = [];
|
|
11678
11813
|
let page = 1;
|
|
11679
11814
|
const perPage = 100;
|
|
11680
11815
|
while (true) {
|
|
11681
|
-
const response = await
|
|
11682
|
-
this.fetchFn,
|
|
11683
|
-
`${this.apiBase}/repos/${this.owner}/${this.repo}/issues?state=all&per_page=${perPage}&page=${page}${labelsParam}`,
|
|
11684
|
-
{
|
|
11685
|
-
method: "GET",
|
|
11686
|
-
headers: this.headers()
|
|
11687
|
-
},
|
|
11688
|
-
this.retryOpts
|
|
11689
|
-
);
|
|
11816
|
+
const response = await this.fetchIssuePage(page, labelsParam);
|
|
11690
11817
|
if (!response.ok) {
|
|
11691
11818
|
const text = await response.text();
|
|
11692
11819
|
return Err3(new Error(`GitHub API error ${response.status}: ${text}`));
|
|
@@ -11694,13 +11821,7 @@ var GitHubIssuesSyncAdapter = class {
|
|
|
11694
11821
|
const data = await response.json();
|
|
11695
11822
|
const issues = data.filter((d) => !d.pull_request);
|
|
11696
11823
|
for (const issue of issues) {
|
|
11697
|
-
tickets.push(
|
|
11698
|
-
externalId: buildExternalId(this.owner, this.repo, issue.number),
|
|
11699
|
-
title: issue.title,
|
|
11700
|
-
status: issue.state,
|
|
11701
|
-
labels: issue.labels.map((l) => l.name),
|
|
11702
|
-
assignee: issue.assignee ? `@${issue.assignee.login}` : null
|
|
11703
|
-
});
|
|
11824
|
+
tickets.push(this.issueToTicketState(issue));
|
|
11704
11825
|
}
|
|
11705
11826
|
if (data.length < perPage) break;
|
|
11706
11827
|
page++;
|
|
@@ -11795,6 +11916,22 @@ async function syncToExternal(roadmap, adapter, config, prefetchedTickets) {
|
|
|
11795
11916
|
}
|
|
11796
11917
|
return result;
|
|
11797
11918
|
}
|
|
11919
|
+
function applyTicketToFeature(ticketState, feature, config, forceSync, result) {
|
|
11920
|
+
if (ticketState.assignee !== feature.assignee) {
|
|
11921
|
+
result.assignmentChanges.push({
|
|
11922
|
+
feature: feature.name,
|
|
11923
|
+
from: feature.assignee,
|
|
11924
|
+
to: ticketState.assignee
|
|
11925
|
+
});
|
|
11926
|
+
feature.assignee = ticketState.assignee;
|
|
11927
|
+
}
|
|
11928
|
+
const resolvedStatus = resolveReverseStatus(ticketState.status, ticketState.labels, config);
|
|
11929
|
+
if (!resolvedStatus || resolvedStatus === feature.status) return;
|
|
11930
|
+
const newStatus = resolvedStatus;
|
|
11931
|
+
if (!forceSync && isRegression(feature.status, newStatus)) return;
|
|
11932
|
+
if (!forceSync && feature.status === "blocked" && newStatus === "planned") return;
|
|
11933
|
+
feature.status = newStatus;
|
|
11934
|
+
}
|
|
11798
11935
|
async function syncFromExternal(roadmap, adapter, config, options, prefetchedTickets) {
|
|
11799
11936
|
const result = emptySyncResult();
|
|
11800
11937
|
const forceSync = options?.forceSync ?? false;
|
|
@@ -11821,25 +11958,7 @@ async function syncFromExternal(roadmap, adapter, config, options, prefetchedTic
|
|
|
11821
11958
|
for (const ticketState of tickets) {
|
|
11822
11959
|
const feature = featureByExternalId.get(ticketState.externalId);
|
|
11823
11960
|
if (!feature) continue;
|
|
11824
|
-
|
|
11825
|
-
result.assignmentChanges.push({
|
|
11826
|
-
feature: feature.name,
|
|
11827
|
-
from: feature.assignee,
|
|
11828
|
-
to: ticketState.assignee
|
|
11829
|
-
});
|
|
11830
|
-
feature.assignee = ticketState.assignee;
|
|
11831
|
-
}
|
|
11832
|
-
const resolvedStatus = resolveReverseStatus(ticketState.status, ticketState.labels, config);
|
|
11833
|
-
if (resolvedStatus && resolvedStatus !== feature.status) {
|
|
11834
|
-
const newStatus = resolvedStatus;
|
|
11835
|
-
if (!forceSync && isRegression(feature.status, newStatus)) {
|
|
11836
|
-
continue;
|
|
11837
|
-
}
|
|
11838
|
-
if (!forceSync && feature.status === "blocked" && newStatus === "planned") {
|
|
11839
|
-
continue;
|
|
11840
|
-
}
|
|
11841
|
-
feature.status = newStatus;
|
|
11842
|
-
}
|
|
11961
|
+
applyTicketToFeature(ticketState, feature, config, forceSync, result);
|
|
11843
11962
|
}
|
|
11844
11963
|
return result;
|
|
11845
11964
|
}
|
|
@@ -11887,6 +12006,24 @@ var PRIORITY_RANK = {
|
|
|
11887
12006
|
var POSITION_WEIGHT = 0.5;
|
|
11888
12007
|
var DEPENDENTS_WEIGHT = 0.3;
|
|
11889
12008
|
var AFFINITY_WEIGHT = 0.2;
|
|
12009
|
+
function isEligibleCandidate(feature, allFeatureNames, doneFeatures) {
|
|
12010
|
+
if (feature.status !== "planned" && feature.status !== "backlog") return false;
|
|
12011
|
+
const isBlocked = feature.blockedBy.some((blocker) => {
|
|
12012
|
+
const key = blocker.toLowerCase();
|
|
12013
|
+
return allFeatureNames.has(key) && !doneFeatures.has(key);
|
|
12014
|
+
});
|
|
12015
|
+
return !isBlocked;
|
|
12016
|
+
}
|
|
12017
|
+
function computeAffinityScore(feature, milestoneName, milestoneMap, userCompletedFeatures) {
|
|
12018
|
+
if (userCompletedFeatures.size === 0) return 0;
|
|
12019
|
+
const completedBlocker = feature.blockedBy.some(
|
|
12020
|
+
(b) => userCompletedFeatures.has(b.toLowerCase())
|
|
12021
|
+
);
|
|
12022
|
+
if (completedBlocker) return 1;
|
|
12023
|
+
const siblings = milestoneMap.get(milestoneName) ?? [];
|
|
12024
|
+
const completedSibling = siblings.some((s) => userCompletedFeatures.has(s));
|
|
12025
|
+
return completedSibling ? 0.5 : 0;
|
|
12026
|
+
}
|
|
11890
12027
|
function scoreRoadmapCandidates(roadmap, options) {
|
|
11891
12028
|
const allFeatures = roadmap.milestones.flatMap((m) => m.features);
|
|
11892
12029
|
const allFeatureNames = new Set(allFeatures.map((f) => f.name.toLowerCase()));
|
|
@@ -11925,33 +12062,18 @@ function scoreRoadmapCandidates(roadmap, options) {
|
|
|
11925
12062
|
const candidates = [];
|
|
11926
12063
|
let globalPosition = 0;
|
|
11927
12064
|
for (const ms of roadmap.milestones) {
|
|
11928
|
-
for (
|
|
11929
|
-
const feature = ms.features[featureIdx];
|
|
12065
|
+
for (const feature of ms.features) {
|
|
11930
12066
|
globalPosition++;
|
|
11931
|
-
if (feature
|
|
11932
|
-
const isBlocked = feature.blockedBy.some((blocker) => {
|
|
11933
|
-
const key = blocker.toLowerCase();
|
|
11934
|
-
return allFeatureNames.has(key) && !doneFeatures.has(key);
|
|
11935
|
-
});
|
|
11936
|
-
if (isBlocked) continue;
|
|
12067
|
+
if (!isEligibleCandidate(feature, allFeatureNames, doneFeatures)) continue;
|
|
11937
12068
|
const positionScore = 1 - (globalPosition - 1) / totalPositions;
|
|
11938
12069
|
const deps = dependentsCount.get(feature.name.toLowerCase()) ?? 0;
|
|
11939
12070
|
const dependentsScore = deps / maxDependents;
|
|
11940
|
-
|
|
11941
|
-
|
|
11942
|
-
|
|
11943
|
-
|
|
11944
|
-
|
|
11945
|
-
|
|
11946
|
-
affinityScore = 1;
|
|
11947
|
-
} else {
|
|
11948
|
-
const siblings = milestoneMap.get(ms.name) ?? [];
|
|
11949
|
-
const completedSiblings = siblings.filter((s) => userCompletedFeatures.has(s));
|
|
11950
|
-
if (completedSiblings.length > 0) {
|
|
11951
|
-
affinityScore = 0.5;
|
|
11952
|
-
}
|
|
11953
|
-
}
|
|
11954
|
-
}
|
|
12071
|
+
const affinityScore = computeAffinityScore(
|
|
12072
|
+
feature,
|
|
12073
|
+
ms.name,
|
|
12074
|
+
milestoneMap,
|
|
12075
|
+
userCompletedFeatures
|
|
12076
|
+
);
|
|
11955
12077
|
const weightedScore = POSITION_WEIGHT * positionScore + DEPENDENTS_WEIGHT * dependentsScore + AFFINITY_WEIGHT * affinityScore;
|
|
11956
12078
|
const priorityTier = feature.priority ? PRIORITY_RANK[feature.priority] : null;
|
|
11957
12079
|
candidates.push({
|
|
@@ -13012,6 +13134,27 @@ function aggregateByDay(records) {
|
|
|
13012
13134
|
// src/usage/jsonl-reader.ts
|
|
13013
13135
|
import * as fs30 from "fs";
|
|
13014
13136
|
import * as path29 from "path";
|
|
13137
|
+
function extractTokenUsage(entry, lineNumber) {
|
|
13138
|
+
const tokenUsage = entry.token_usage;
|
|
13139
|
+
if (!tokenUsage || typeof tokenUsage !== "object") {
|
|
13140
|
+
console.warn(
|
|
13141
|
+
`[harness usage] Skipping malformed JSONL line ${lineNumber}: missing token_usage`
|
|
13142
|
+
);
|
|
13143
|
+
return null;
|
|
13144
|
+
}
|
|
13145
|
+
return tokenUsage;
|
|
13146
|
+
}
|
|
13147
|
+
function applyOptionalFields2(record, entry) {
|
|
13148
|
+
if (entry.cache_creation_tokens != null) {
|
|
13149
|
+
record.cacheCreationTokens = entry.cache_creation_tokens;
|
|
13150
|
+
}
|
|
13151
|
+
if (entry.cache_read_tokens != null) {
|
|
13152
|
+
record.cacheReadTokens = entry.cache_read_tokens;
|
|
13153
|
+
}
|
|
13154
|
+
if (entry.model != null) {
|
|
13155
|
+
record.model = entry.model;
|
|
13156
|
+
}
|
|
13157
|
+
}
|
|
13015
13158
|
function parseLine(line, lineNumber) {
|
|
13016
13159
|
let entry;
|
|
13017
13160
|
try {
|
|
@@ -13020,13 +13163,8 @@ function parseLine(line, lineNumber) {
|
|
|
13020
13163
|
console.warn(`[harness usage] Skipping malformed JSONL line ${lineNumber}`);
|
|
13021
13164
|
return null;
|
|
13022
13165
|
}
|
|
13023
|
-
const tokenUsage = entry
|
|
13024
|
-
if (!tokenUsage
|
|
13025
|
-
console.warn(
|
|
13026
|
-
`[harness usage] Skipping malformed JSONL line ${lineNumber}: missing token_usage`
|
|
13027
|
-
);
|
|
13028
|
-
return null;
|
|
13029
|
-
}
|
|
13166
|
+
const tokenUsage = extractTokenUsage(entry, lineNumber);
|
|
13167
|
+
if (!tokenUsage) return null;
|
|
13030
13168
|
const inputTokens = tokenUsage.input_tokens ?? 0;
|
|
13031
13169
|
const outputTokens = tokenUsage.output_tokens ?? 0;
|
|
13032
13170
|
const record = {
|
|
@@ -13038,15 +13176,7 @@ function parseLine(line, lineNumber) {
|
|
|
13038
13176
|
totalTokens: inputTokens + outputTokens
|
|
13039
13177
|
}
|
|
13040
13178
|
};
|
|
13041
|
-
|
|
13042
|
-
record.cacheCreationTokens = entry.cache_creation_tokens;
|
|
13043
|
-
}
|
|
13044
|
-
if (entry.cache_read_tokens != null) {
|
|
13045
|
-
record.cacheReadTokens = entry.cache_read_tokens;
|
|
13046
|
-
}
|
|
13047
|
-
if (entry.model != null) {
|
|
13048
|
-
record.model = entry.model;
|
|
13049
|
-
}
|
|
13179
|
+
applyOptionalFields2(record, entry);
|
|
13050
13180
|
return record;
|
|
13051
13181
|
}
|
|
13052
13182
|
function readCostRecords(projectRoot) {
|
|
@@ -13081,6 +13211,14 @@ function extractUsage(entry) {
|
|
|
13081
13211
|
const usage = message.usage;
|
|
13082
13212
|
return usage && typeof usage === "object" && !Array.isArray(usage) ? usage : null;
|
|
13083
13213
|
}
|
|
13214
|
+
function applyOptionalCCFields(record, message, usage) {
|
|
13215
|
+
const model = message.model;
|
|
13216
|
+
if (model) record.model = model;
|
|
13217
|
+
const cacheCreate = usage.cache_creation_input_tokens;
|
|
13218
|
+
const cacheRead = usage.cache_read_input_tokens;
|
|
13219
|
+
if (typeof cacheCreate === "number" && cacheCreate > 0) record.cacheCreationTokens = cacheCreate;
|
|
13220
|
+
if (typeof cacheRead === "number" && cacheRead > 0) record.cacheReadTokens = cacheRead;
|
|
13221
|
+
}
|
|
13084
13222
|
function buildRecord(entry, usage) {
|
|
13085
13223
|
const inputTokens = Number(usage.input_tokens) || 0;
|
|
13086
13224
|
const outputTokens = Number(usage.output_tokens) || 0;
|
|
@@ -13091,12 +13229,7 @@ function buildRecord(entry, usage) {
|
|
|
13091
13229
|
tokens: { inputTokens, outputTokens, totalTokens: inputTokens + outputTokens },
|
|
13092
13230
|
_source: "claude-code"
|
|
13093
13231
|
};
|
|
13094
|
-
|
|
13095
|
-
if (model) record.model = model;
|
|
13096
|
-
const cacheCreate = usage.cache_creation_input_tokens;
|
|
13097
|
-
const cacheRead = usage.cache_read_input_tokens;
|
|
13098
|
-
if (typeof cacheCreate === "number" && cacheCreate > 0) record.cacheCreationTokens = cacheCreate;
|
|
13099
|
-
if (typeof cacheRead === "number" && cacheRead > 0) record.cacheReadTokens = cacheRead;
|
|
13232
|
+
applyOptionalCCFields(record, message, usage);
|
|
13100
13233
|
return record;
|
|
13101
13234
|
}
|
|
13102
13235
|
function parseCCLine(line, filePath, lineNumber) {
|
|
@@ -13164,7 +13297,7 @@ function parseCCRecords() {
|
|
|
13164
13297
|
}
|
|
13165
13298
|
|
|
13166
13299
|
// src/index.ts
|
|
13167
|
-
var VERSION = "0.
|
|
13300
|
+
var VERSION = "0.21.1";
|
|
13168
13301
|
export {
|
|
13169
13302
|
AGENT_DESCRIPTORS,
|
|
13170
13303
|
ARCHITECTURE_DESCRIPTOR,
|
|
@@ -13194,7 +13327,6 @@ export {
|
|
|
13194
13327
|
ConfirmationSchema,
|
|
13195
13328
|
ConsoleSink,
|
|
13196
13329
|
ConstraintRuleSchema,
|
|
13197
|
-
ContentPipeline,
|
|
13198
13330
|
ContributingFeatureSchema,
|
|
13199
13331
|
ContributionsSchema,
|
|
13200
13332
|
CouplingCollector,
|