agent-roast 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +181 -22
- package/dist/{chunk-O4SB2FCT.js → chunk-RU5OYHLR.js} +999 -221
- package/dist/cli.js +125 -16
- package/dist/index.d.ts +143 -22
- package/dist/index.js +37 -3
- package/package.json +5 -3
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import
|
|
4
|
+
import path6 from "path";
|
|
5
5
|
|
|
6
6
|
// src/git.ts
|
|
7
7
|
import { spawn, spawnSync } from "child_process";
|
|
@@ -84,6 +84,8 @@ var HERMETIC_GIT_FLAGS = [
|
|
|
84
84
|
"-c",
|
|
85
85
|
"core.quotepath=false",
|
|
86
86
|
"-c",
|
|
87
|
+
"core.fsmonitor=false",
|
|
88
|
+
"-c",
|
|
87
89
|
"diff.noprefix=false",
|
|
88
90
|
"-c",
|
|
89
91
|
"diff.mnemonicPrefix=false",
|
|
@@ -433,7 +435,7 @@ function streamGitLog(cwd = process.cwd(), since = "90 days ago") {
|
|
|
433
435
|
});
|
|
434
436
|
return output;
|
|
435
437
|
}
|
|
436
|
-
function streamGitDiff(cwd, baseSha, headSha) {
|
|
438
|
+
function streamGitDiff(cwd, baseSha, headSha, staged = false) {
|
|
437
439
|
const gitArgs = [
|
|
438
440
|
...HERMETIC_GIT_FLAGS,
|
|
439
441
|
"diff",
|
|
@@ -443,9 +445,9 @@ function streamGitDiff(cwd, baseSha, headSha) {
|
|
|
443
445
|
"--no-textconv",
|
|
444
446
|
"--src-prefix=a/",
|
|
445
447
|
"--dst-prefix=b/",
|
|
448
|
+
...staged ? ["--cached"] : [],
|
|
446
449
|
"--end-of-options",
|
|
447
|
-
baseSha,
|
|
448
|
-
headSha,
|
|
450
|
+
...staged ? [] : [baseSha, headSha],
|
|
449
451
|
...COMMON_EXCLUDE_SPECS
|
|
450
452
|
];
|
|
451
453
|
const output = new PassThrough();
|
|
@@ -542,9 +544,155 @@ var GitResolutionError = class extends Error {
|
|
|
542
544
|
}
|
|
543
545
|
};
|
|
544
546
|
|
|
547
|
+
// src/source.ts
|
|
548
|
+
var SourceMasker = class {
|
|
549
|
+
blockComment = false;
|
|
550
|
+
quote = "";
|
|
551
|
+
triple = false;
|
|
552
|
+
previousLine = 0;
|
|
553
|
+
python;
|
|
554
|
+
javascript;
|
|
555
|
+
hashComments;
|
|
556
|
+
backticks;
|
|
557
|
+
constructor(filename) {
|
|
558
|
+
this.python = filename.endsWith(".py");
|
|
559
|
+
this.hashComments = this.python || filename.endsWith(".rb");
|
|
560
|
+
this.backticks = /\.(?:go|[cm]?[jt]sx?|vue|svelte)$/.test(filename);
|
|
561
|
+
this.javascript = /\.(?:[cm]?[jt]sx?|vue|svelte)$/.test(filename);
|
|
562
|
+
}
|
|
563
|
+
mask(line, number) {
|
|
564
|
+
if (number !== this.previousLine + 1) {
|
|
565
|
+
this.blockComment = false;
|
|
566
|
+
this.quote = "";
|
|
567
|
+
this.triple = false;
|
|
568
|
+
}
|
|
569
|
+
this.previousLine = number;
|
|
570
|
+
const code = Array(line.length).fill(" ");
|
|
571
|
+
const comments = Array(line.length).fill(" ");
|
|
572
|
+
let i = 0;
|
|
573
|
+
let prefix = "";
|
|
574
|
+
while (i < line.length) {
|
|
575
|
+
if (this.blockComment) {
|
|
576
|
+
if (line.startsWith("*/", i)) {
|
|
577
|
+
this.blockComment = false;
|
|
578
|
+
i += 2;
|
|
579
|
+
} else i++;
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
if (this.quote) {
|
|
583
|
+
if (line[i] === "\\") {
|
|
584
|
+
i += 2;
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
const delimiter = this.triple ? this.quote.repeat(3) : this.quote;
|
|
588
|
+
if (line.startsWith(delimiter, i)) {
|
|
589
|
+
i += delimiter.length;
|
|
590
|
+
this.quote = "";
|
|
591
|
+
this.triple = false;
|
|
592
|
+
} else i++;
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
if (!this.python && line.startsWith("//", i) || this.hashComments && line[i] === "#") {
|
|
596
|
+
for (let j = i; j < line.length; j++) comments[j] = line[j];
|
|
597
|
+
break;
|
|
598
|
+
}
|
|
599
|
+
if (!this.python && line.startsWith("/*", i)) {
|
|
600
|
+
this.blockComment = true;
|
|
601
|
+
i += 2;
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
const char = line[i];
|
|
605
|
+
if (char === '"' || char === "'" || this.backticks && char === "`") {
|
|
606
|
+
this.quote = char;
|
|
607
|
+
this.triple = this.python && line.startsWith(char.repeat(3), i);
|
|
608
|
+
i += this.triple ? 3 : 1;
|
|
609
|
+
prefix += " literal ";
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
if (this.javascript && char === "/" && (/(?:[=(:,[!&|?{;]|\breturn|\byield)\s*$/.test(prefix) || !prefix.trim())) {
|
|
613
|
+
let j = i + 1;
|
|
614
|
+
let inClass = false;
|
|
615
|
+
for (; j < line.length; j++) {
|
|
616
|
+
if (line[j] === "\\") {
|
|
617
|
+
j++;
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
if (line[j] === "[") inClass = true;
|
|
621
|
+
if (line[j] === "]") inClass = false;
|
|
622
|
+
if (line[j] === "/" && !inClass) break;
|
|
623
|
+
}
|
|
624
|
+
if (j < line.length) {
|
|
625
|
+
j++;
|
|
626
|
+
while (/[a-z]/i.test(line[j] || "") && j < line.length) j++;
|
|
627
|
+
i = j;
|
|
628
|
+
prefix += " literal ";
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
code[i] = char;
|
|
633
|
+
prefix += char;
|
|
634
|
+
i++;
|
|
635
|
+
}
|
|
636
|
+
if (this.quote !== "`" && !this.triple) this.quote = "";
|
|
637
|
+
return { code: code.join(""), comments: comments.join("") };
|
|
638
|
+
}
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
// src/rules.ts
|
|
642
|
+
var RULE_GUIDES = {
|
|
643
|
+
"test-skip": {
|
|
644
|
+
title: "Skipped or disabled test",
|
|
645
|
+
why: "The test runner may report success without executing this behavior check.",
|
|
646
|
+
suggestion: "Re-enable the test and fix its failure. If a skip is intentional, document its reason and track when to restore it.",
|
|
647
|
+
example: 'it.skip("auth", ...) \u2192 it("auth", ...) after fixing the failing behavior'
|
|
648
|
+
},
|
|
649
|
+
"trivial-assert": {
|
|
650
|
+
title: "Assertion that always passes",
|
|
651
|
+
why: "Comparing a constant to itself does not check the implementation and cannot catch regressions.",
|
|
652
|
+
suggestion: "Call the real behavior and assert its output or observable side effect instead of comparing constants.",
|
|
653
|
+
example: "expect(true).toBe(true) \u2192 expect(canSignIn(validUser)).toBe(true)"
|
|
654
|
+
},
|
|
655
|
+
"type-escape": {
|
|
656
|
+
title: "Type or linter check bypass",
|
|
657
|
+
why: "A suppression can hide incompatible values or a disabled correctness check. Some suppressions are justified.",
|
|
658
|
+
suggestion: "Narrow or validate the value at the boundary. Keep a suppression only with a specific reason and the smallest possible scope.",
|
|
659
|
+
example: "response as any \u2192 parse and validate response before using it"
|
|
660
|
+
},
|
|
661
|
+
"swallowed-error": {
|
|
662
|
+
title: "Error discarded without handling",
|
|
663
|
+
why: "An empty handler makes a failed operation look successful to callers and hides diagnostics.",
|
|
664
|
+
suggestion: "Handle the expected error explicitly, return an observable failure, or rethrow it. Document an intentional best-effort operation.",
|
|
665
|
+
example: "catch (error) {} \u2192 catch (error) { throw new Error('Save failed', { cause: error }); }"
|
|
666
|
+
},
|
|
667
|
+
"stub-abandonment": {
|
|
668
|
+
title: "Unimplemented code path",
|
|
669
|
+
why: "A placeholder throws at runtime when a caller reaches the unfinished path. Abstract methods may be intentional.",
|
|
670
|
+
suggestion: "Implement the path or make its unsupported state explicit to callers. Check whether this is an intentional abstract contract.",
|
|
671
|
+
example: "throw new Error('Not implemented') \u2192 implement the operation and add a behavior test"
|
|
672
|
+
},
|
|
673
|
+
"panic-loop": {
|
|
674
|
+
title: "Rapid overlapping fix commits",
|
|
675
|
+
why: "Repeated short fix cycles can suggest a missing reproduction or feedback loop; timing alone does not prove poor work.",
|
|
676
|
+
suggestion: "Find a minimal reproduction, add a regression test, and verify the complete fix before the next patch.",
|
|
677
|
+
example: "fix \u2192 revert \u2192 fix \u2192 add a reproducer and validate the final behavior"
|
|
678
|
+
}
|
|
679
|
+
};
|
|
680
|
+
|
|
545
681
|
// src/config.ts
|
|
546
682
|
import * as fs from "fs";
|
|
547
683
|
import * as path from "path";
|
|
684
|
+
|
|
685
|
+
// src/types.ts
|
|
686
|
+
var VALID_DETECTORS = [
|
|
687
|
+
"test-skip",
|
|
688
|
+
"type-escape",
|
|
689
|
+
"swallowed-error",
|
|
690
|
+
"panic-loop",
|
|
691
|
+
"trivial-assert",
|
|
692
|
+
"stub-abandonment"
|
|
693
|
+
];
|
|
694
|
+
|
|
695
|
+
// src/config.ts
|
|
548
696
|
var DEFAULT_CONFIG = {
|
|
549
697
|
failOn: ["test-skip", "trivial-assert"],
|
|
550
698
|
ignorePaths: [
|
|
@@ -571,31 +719,71 @@ var CONFIG_FILENAMES = [
|
|
|
571
719
|
".agentroastrc",
|
|
572
720
|
"agent-roast.config.json"
|
|
573
721
|
];
|
|
722
|
+
var ConfigError = class extends Error {
|
|
723
|
+
constructor(message) {
|
|
724
|
+
super(message);
|
|
725
|
+
this.name = "ConfigError";
|
|
726
|
+
}
|
|
727
|
+
};
|
|
728
|
+
function validateConfig(value) {
|
|
729
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
730
|
+
throw new ConfigError("Configuration must be a JSON object.");
|
|
731
|
+
}
|
|
732
|
+
const config = value;
|
|
733
|
+
const knownRules = new Set(VALID_DETECTORS);
|
|
734
|
+
if (config.failOn !== void 0 && (!Array.isArray(config.failOn) || config.failOn.some((rule) => typeof rule !== "string" || !knownRules.has(rule)))) {
|
|
735
|
+
throw new ConfigError(`failOn must contain known rules: ${VALID_DETECTORS.join(", ")}`);
|
|
736
|
+
}
|
|
737
|
+
if (config.ignorePaths !== void 0 && (!Array.isArray(config.ignorePaths) || config.ignorePaths.some((pattern) => typeof pattern !== "string"))) {
|
|
738
|
+
throw new ConfigError("ignorePaths must be an array of strings.");
|
|
739
|
+
}
|
|
740
|
+
if (config.scoreThreshold !== void 0 && (typeof config.scoreThreshold !== "number" || !Number.isFinite(config.scoreThreshold) || config.scoreThreshold < 0 || config.scoreThreshold > 100)) {
|
|
741
|
+
throw new ConfigError("scoreThreshold must be a number between 0 and 100.");
|
|
742
|
+
}
|
|
743
|
+
if (config.panicLoopWindowMinutes !== void 0 && (typeof config.panicLoopWindowMinutes !== "number" || !Number.isFinite(config.panicLoopWindowMinutes) || config.panicLoopWindowMinutes <= 0)) {
|
|
744
|
+
throw new ConfigError("panicLoopWindowMinutes must be a positive number.");
|
|
745
|
+
}
|
|
746
|
+
if (config.rules !== void 0) {
|
|
747
|
+
if (!config.rules || typeof config.rules !== "object" || Array.isArray(config.rules)) {
|
|
748
|
+
throw new ConfigError("rules must be an object.");
|
|
749
|
+
}
|
|
750
|
+
for (const [rule, level] of Object.entries(config.rules)) {
|
|
751
|
+
if (!knownRules.has(rule) || !["error", "warn", "off"].includes(String(level))) {
|
|
752
|
+
throw new ConfigError(`Invalid rule '${rule}' or level '${level}'. Use known rules with error, warn, or off.`);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
function configFromText(text, source) {
|
|
758
|
+
try {
|
|
759
|
+
const parsed = JSON.parse(text);
|
|
760
|
+
validateConfig(parsed);
|
|
761
|
+
const rules = { ...DEFAULT_CONFIG.rules, ...parsed.rules };
|
|
762
|
+
const failOn = parsed.failOn ?? Object.entries(rules).filter(([, level]) => level === "error").map(([rule]) => rule);
|
|
763
|
+
return {
|
|
764
|
+
...DEFAULT_CONFIG,
|
|
765
|
+
...parsed,
|
|
766
|
+
failOn,
|
|
767
|
+
ignorePaths: [.../* @__PURE__ */ new Set([...DEFAULT_CONFIG.ignorePaths || [], ...parsed.ignorePaths || []])],
|
|
768
|
+
rules
|
|
769
|
+
};
|
|
770
|
+
} catch (error) {
|
|
771
|
+
throw new ConfigError(`Invalid ${source}: ${error instanceof Error ? error.message : String(error)}`);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
574
774
|
function loadConfig(cwd = process.cwd()) {
|
|
575
775
|
for (const filename of CONFIG_FILENAMES) {
|
|
576
776
|
const filePath = path.join(cwd, filename);
|
|
577
777
|
if (fs.existsSync(filePath)) {
|
|
578
778
|
try {
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
if (
|
|
582
|
-
|
|
583
|
-
...DEFAULT_CONFIG,
|
|
584
|
-
...parsed,
|
|
585
|
-
failOn: Array.isArray(parsed.failOn) ? parsed.failOn : DEFAULT_CONFIG.failOn,
|
|
586
|
-
ignorePaths: Array.isArray(parsed.ignorePaths) ? Array.from(/* @__PURE__ */ new Set([...DEFAULT_CONFIG.ignorePaths || [], ...parsed.ignorePaths])) : DEFAULT_CONFIG.ignorePaths,
|
|
587
|
-
rules: {
|
|
588
|
-
...DEFAULT_CONFIG.rules,
|
|
589
|
-
...parsed.rules || {}
|
|
590
|
-
}
|
|
591
|
-
};
|
|
592
|
-
}
|
|
593
|
-
} catch (err) {
|
|
594
|
-
console.warn(`Warning: Failed to parse ${filename}: ${err.message}. Using default configuration.`);
|
|
779
|
+
return configFromText(fs.readFileSync(filePath, "utf8"), filename);
|
|
780
|
+
} catch (error) {
|
|
781
|
+
if (error instanceof ConfigError) throw error;
|
|
782
|
+
throw new ConfigError(`Cannot read ${filename}: ${error instanceof Error ? error.message : String(error)}`);
|
|
595
783
|
}
|
|
596
784
|
}
|
|
597
785
|
}
|
|
598
|
-
return {
|
|
786
|
+
return configFromText("{}", "default configuration");
|
|
599
787
|
}
|
|
600
788
|
function matchIgnorePath(filePath, ignorePatterns) {
|
|
601
789
|
if (!ignorePatterns || ignorePatterns.length === 0) {
|
|
@@ -605,7 +793,25 @@ function matchIgnorePath(filePath, ignorePatterns) {
|
|
|
605
793
|
for (const pattern of ignorePatterns) {
|
|
606
794
|
if (!pattern) continue;
|
|
607
795
|
const cleanPattern = pattern.replace(/\\/g, "/");
|
|
608
|
-
let regexPattern =
|
|
796
|
+
let regexPattern = "";
|
|
797
|
+
for (let i = 0; i < cleanPattern.length; i++) {
|
|
798
|
+
const char = cleanPattern[i];
|
|
799
|
+
if (char === "*" && cleanPattern[i + 1] === "*") {
|
|
800
|
+
i++;
|
|
801
|
+
if (cleanPattern[i + 1] === "/") {
|
|
802
|
+
regexPattern += "(?:.*/)?";
|
|
803
|
+
i++;
|
|
804
|
+
} else {
|
|
805
|
+
regexPattern += ".*";
|
|
806
|
+
}
|
|
807
|
+
} else if (char === "*") {
|
|
808
|
+
regexPattern += "[^/]*";
|
|
809
|
+
} else if (char === "?") {
|
|
810
|
+
regexPattern += "[^/]";
|
|
811
|
+
} else {
|
|
812
|
+
regexPattern += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
813
|
+
}
|
|
814
|
+
}
|
|
609
815
|
if (!regexPattern.startsWith("^") && !regexPattern.startsWith("(?:^")) {
|
|
610
816
|
regexPattern = "(?:^|/)" + regexPattern;
|
|
611
817
|
}
|
|
@@ -628,7 +834,7 @@ function matchIgnorePath(filePath, ignorePatterns) {
|
|
|
628
834
|
|
|
629
835
|
// src/metrics.ts
|
|
630
836
|
var TEST_SKIP_PATTERNS = [
|
|
631
|
-
{ regex:
|
|
837
|
+
{ regex: /\b(?:it|test|describe)\.skip\b/, name: "skipped test (it.skip / describe.skip)" },
|
|
632
838
|
{ regex: /\b(?:xit|xtest)\(/, name: "disabled test (xit / xtest)" },
|
|
633
839
|
{ regex: /@pytest\.mark\.skip\b/, name: "skipped pytest (@pytest.mark.skip)" },
|
|
634
840
|
{ regex: /\bpytest\.skip\(/, name: "imperative pytest skip (pytest.skip)" },
|
|
@@ -652,9 +858,9 @@ var SWALLOWED_ERROR_PATTERNS = [
|
|
|
652
858
|
{ regex: /except(?:\s+[^:]*)?:\s*pass\b/, name: "silent python error swallow (except: pass)" }
|
|
653
859
|
];
|
|
654
860
|
var TRIVIAL_ASSERT_PATTERNS = [
|
|
655
|
-
{ regex: /expect\s*\(\s*(
|
|
656
|
-
{ regex: /expect\s*\(\s*(
|
|
657
|
-
{ regex: /\bassert\s+(?:True|
|
|
861
|
+
{ regex: /expect\s*\(\s*(true|false|1|0|null|undefined)\s*\)\.toBe\s*\(\s*\1\s*\)/, name: "trivial assertion (expect(val).toBe(val))" },
|
|
862
|
+
{ regex: /expect\s*\(\s*(true|false|1|0|null|undefined)\s*\)\.toEqual\s*\(\s*\1\s*\)/, name: "trivial assertion (expect(val).toEqual(val))" },
|
|
863
|
+
{ regex: /\bassert\s+(?:True|1\s*==\s*1)\s*(?=$|#|,)/, name: "trivial python assert (assert True / 1 == 1)" },
|
|
658
864
|
{ regex: /self\.assertTrue\s*\(\s*(?:True|1)\s*\)/, name: "trivial assert (self.assertTrue(True))" },
|
|
659
865
|
{ regex: /self\.assertEqual\s*\(\s*(?:1\s*,\s*1|True\s*,\s*True)\s*\)/, name: "trivial assert (self.assertEqual(1, 1))" },
|
|
660
866
|
{ regex: /assert\.(?:strictEqual|equal)\s*\(\s*(?:true\s*,\s*true|1\s*,\s*1)\s*\)/, name: "trivial assert (assert.equal(1, 1))" }
|
|
@@ -687,7 +893,7 @@ var CODE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
|
687
893
|
".c"
|
|
688
894
|
]);
|
|
689
895
|
var IGNORED_PATH_REGEX = /(?:^|\/)(?:fixtures|__fixtures__|testdata|mocks|test_data|__snapshots__|sample[s]?)\//i;
|
|
690
|
-
var TEST_PATH_REGEX = /(?:^|\/)(?:tests?|__tests__|specs?)\/|[._-](?:test|spec)\.[a-z0-9]+$/i;
|
|
896
|
+
var TEST_PATH_REGEX = /(?:^|\/)test_[^/]+\.py$|(?:^|\/)(?:tests?|__tests__|specs?)\/|[._-](?:test|spec)\.[a-z0-9]+$/i;
|
|
691
897
|
function isCodeFile(filePath, customIgnorePaths) {
|
|
692
898
|
if (IGNORED_PATH_REGEX.test(filePath)) {
|
|
693
899
|
return false;
|
|
@@ -712,11 +918,14 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
712
918
|
hasSupportedChanges = true;
|
|
713
919
|
filesInspected++;
|
|
714
920
|
linesAdded += file.addedLines.length;
|
|
921
|
+
const masker = new SourceMasker(file.path);
|
|
922
|
+
const maskedLines = [];
|
|
715
923
|
for (const added of file.addedLines) {
|
|
716
924
|
const rawLine = added.line;
|
|
717
|
-
const
|
|
718
|
-
const
|
|
719
|
-
|
|
925
|
+
const masked = masker.mask(rawLine, added.lineNumber);
|
|
926
|
+
const cleanedLine = masked.code;
|
|
927
|
+
maskedLines.push({ code: cleanedLine, raw: rawLine, number: added.lineNumber });
|
|
928
|
+
const isComment = !cleanedLine.trim();
|
|
720
929
|
if (!isComment) {
|
|
721
930
|
for (const rule of TEST_SKIP_PATTERNS) {
|
|
722
931
|
if (rule.regex.test(cleanedLine)) {
|
|
@@ -735,7 +944,7 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
735
944
|
}
|
|
736
945
|
let foundEscape = false;
|
|
737
946
|
for (const rule of COMMENT_DIRECTIVE_PATTERNS) {
|
|
738
|
-
if (rule.regex.test(
|
|
947
|
+
if (rule.regex.test(masked.comments)) {
|
|
739
948
|
infractions.push({
|
|
740
949
|
type: "type-escape",
|
|
741
950
|
file: file.path,
|
|
@@ -799,7 +1008,8 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
799
1008
|
}
|
|
800
1009
|
if (!isComment) {
|
|
801
1010
|
for (const rule of STUB_ABANDONMENT_PATTERNS) {
|
|
802
|
-
|
|
1011
|
+
const match = rule.regex.exec(rawLine);
|
|
1012
|
+
if (match && cleanedLine[match.index] === rawLine[match.index]) {
|
|
803
1013
|
infractions.push({
|
|
804
1014
|
type: "stub-abandonment",
|
|
805
1015
|
file: file.path,
|
|
@@ -814,14 +1024,39 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
814
1024
|
}
|
|
815
1025
|
}
|
|
816
1026
|
}
|
|
1027
|
+
for (let index = 0; index < maskedLines.length; index++) {
|
|
1028
|
+
const start = maskedLines[index];
|
|
1029
|
+
const isCatch = /\bcatch\s*(?:\([^)]*\))?\s*\{\s*$/.test(start.code);
|
|
1030
|
+
const isExcept = /\bexcept(?:\s+[^:]*)?:\s*$/.test(start.code);
|
|
1031
|
+
if (!isCatch && !isExcept) continue;
|
|
1032
|
+
let next = index + 1;
|
|
1033
|
+
while (next < maskedLines.length && maskedLines[next].number === maskedLines[next - 1].number + 1 && !maskedLines[next].code.trim()) next++;
|
|
1034
|
+
const end = maskedLines[next];
|
|
1035
|
+
if (!end || end.number !== maskedLines[next - 1].number + 1) continue;
|
|
1036
|
+
const closesCatch = isCatch && /^\s*}/.test(end.code);
|
|
1037
|
+
let closesExcept = isExcept && /^\s*pass\s*$/.test(end.code);
|
|
1038
|
+
if (closesExcept) {
|
|
1039
|
+
const indent = start.raw.search(/\S/);
|
|
1040
|
+
if (end.raw.search(/\S/) <= indent) closesExcept = false;
|
|
1041
|
+
let after = next + 1;
|
|
1042
|
+
while (after < maskedLines.length && maskedLines[after].number === maskedLines[after - 1].number + 1 && !maskedLines[after].code.trim()) after++;
|
|
1043
|
+
if (after < maskedLines.length && maskedLines[after].number === maskedLines[after - 1].number + 1 && maskedLines[after].raw.search(/\S/) > indent) closesExcept = false;
|
|
1044
|
+
}
|
|
1045
|
+
if (closesCatch || closesExcept) infractions.push({
|
|
1046
|
+
type: "swallowed-error",
|
|
1047
|
+
file: file.path,
|
|
1048
|
+
lineNumber: start.number,
|
|
1049
|
+
lineSnippet: maskedLines.slice(index, next + 1).map((line) => line.raw.trim()).join(" "),
|
|
1050
|
+
commitHash,
|
|
1051
|
+
commitDate,
|
|
1052
|
+
reason: closesCatch ? "empty multi-line catch block" : "silent multi-line python error swallow"
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
817
1055
|
}
|
|
818
|
-
return { infractions, hasSupportedChanges, filesInspected, linesAdded };
|
|
1056
|
+
return { infractions: infractions.filter((inf) => options.rules?.[inf.type] !== "off").map((inf) => ({ ...inf, suggestion: RULE_GUIDES[inf.type].suggestion })), hasSupportedChanges, filesInspected, linesAdded };
|
|
819
1057
|
}
|
|
820
1058
|
function stripStringAndRegexLiterals(line) {
|
|
821
|
-
return
|
|
822
|
-
}
|
|
823
|
-
function isCommentOnlyLine(trimmedLine) {
|
|
824
|
-
return trimmedLine.startsWith("//") || trimmedLine.startsWith("/*") || trimmedLine.startsWith("*") || trimmedLine.startsWith("#");
|
|
1059
|
+
return new SourceMasker("source.ts").mask(line, 1).code;
|
|
825
1060
|
}
|
|
826
1061
|
var PANIC_COMMIT_REGEX = /\b(?:fix|quickfix|revert|hotfix|retry|patch|undo|workaround)\b/i;
|
|
827
1062
|
var MAX_INFRACTIONS_PER_COMMIT = 10;
|
|
@@ -842,16 +1077,20 @@ var MetricsCollector = class {
|
|
|
842
1077
|
activePanicChain = null;
|
|
843
1078
|
includeAll;
|
|
844
1079
|
ignorePaths;
|
|
1080
|
+
rules;
|
|
1081
|
+
panicLoopWindowMinutes;
|
|
845
1082
|
constructor(options = {}) {
|
|
846
1083
|
this.includeAll = options.all ?? false;
|
|
847
1084
|
this.ignorePaths = options.ignorePaths;
|
|
1085
|
+
this.rules = options.rules;
|
|
1086
|
+
this.panicLoopWindowMinutes = options.panicLoopWindowMinutes ?? 15;
|
|
848
1087
|
}
|
|
849
1088
|
recordSkipped(stats) {
|
|
850
1089
|
if (stats.skippedLongLines) this.skippedLongLines += stats.skippedLongLines;
|
|
851
1090
|
if (stats.skippedUnknownFiles) this.skippedUnknownFiles += stats.skippedUnknownFiles;
|
|
852
1091
|
}
|
|
853
1092
|
flushPanicChain() {
|
|
854
|
-
if (this.activePanicChain && this.activePanicChain.commitCount >= 2) {
|
|
1093
|
+
if (this.rules?.["panic-loop"] !== "off" && this.activePanicChain && this.activePanicChain.commitCount >= 2) {
|
|
855
1094
|
const chain = this.activePanicChain;
|
|
856
1095
|
const durationMinutes = Math.max(
|
|
857
1096
|
1,
|
|
@@ -876,6 +1115,7 @@ var MetricsCollector = class {
|
|
|
876
1115
|
this.aiLinesAdded += commit.totalAddedCount;
|
|
877
1116
|
}
|
|
878
1117
|
if (!isTarget) {
|
|
1118
|
+
this.flushPanicChain();
|
|
879
1119
|
return;
|
|
880
1120
|
}
|
|
881
1121
|
const subject = commit.message.split("\n")[0];
|
|
@@ -888,7 +1128,7 @@ var MetricsCollector = class {
|
|
|
888
1128
|
if (this.activePanicChain && this.activePanicChain.author.toLowerCase() === commit.authorEmail.toLowerCase()) {
|
|
889
1129
|
const diffMinutes = Math.abs(commitDateMs - this.activePanicChain.latestDate) / (1e3 * 60);
|
|
890
1130
|
const touchedSameFiles = currentActiveFiles.size > 0 && [...currentActiveFiles].some((p) => this.activePanicChain.activeFiles.has(p));
|
|
891
|
-
if (diffMinutes > 0 && diffMinutes <=
|
|
1131
|
+
if (diffMinutes > 0 && diffMinutes <= this.panicLoopWindowMinutes && touchedSameFiles) {
|
|
892
1132
|
this.activePanicChain.commitCount++;
|
|
893
1133
|
this.activePanicChain.latestHash = commit.hash;
|
|
894
1134
|
this.activePanicChain.latestDate = commitDateMs;
|
|
@@ -927,123 +1167,23 @@ var MetricsCollector = class {
|
|
|
927
1167
|
} else {
|
|
928
1168
|
this.flushPanicChain();
|
|
929
1169
|
}
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
this.testSkips.push({
|
|
948
|
-
type: "test-skip",
|
|
949
|
-
file: file.path,
|
|
950
|
-
lineNumber: added.lineNumber,
|
|
951
|
-
lineSnippet: rawLine.trim(),
|
|
952
|
-
commitHash: commit.hash,
|
|
953
|
-
commitDate: commit.date,
|
|
954
|
-
reason: rule.name
|
|
955
|
-
});
|
|
956
|
-
commitSkips++;
|
|
957
|
-
break;
|
|
958
|
-
}
|
|
959
|
-
}
|
|
960
|
-
}
|
|
961
|
-
if (commitEscapes < MAX_INFRACTIONS_PER_COMMIT) {
|
|
962
|
-
for (const rule of COMMENT_DIRECTIVE_PATTERNS) {
|
|
963
|
-
if (rule.regex.test(cleanedLine)) {
|
|
964
|
-
this.typeEscapes.push({
|
|
965
|
-
type: "type-escape",
|
|
966
|
-
file: file.path,
|
|
967
|
-
lineNumber: added.lineNumber,
|
|
968
|
-
lineSnippet: rawLine.trim(),
|
|
969
|
-
commitHash: commit.hash,
|
|
970
|
-
commitDate: commit.date,
|
|
971
|
-
reason: rule.name
|
|
972
|
-
});
|
|
973
|
-
commitEscapes++;
|
|
974
|
-
break;
|
|
975
|
-
}
|
|
976
|
-
}
|
|
977
|
-
if (!isComment && commitEscapes < MAX_INFRACTIONS_PER_COMMIT) {
|
|
978
|
-
for (const rule of CODE_TYPE_ESCAPE_PATTERNS) {
|
|
979
|
-
if (rule.regex.test(cleanedLine)) {
|
|
980
|
-
this.typeEscapes.push({
|
|
981
|
-
type: "type-escape",
|
|
982
|
-
file: file.path,
|
|
983
|
-
lineNumber: added.lineNumber,
|
|
984
|
-
lineSnippet: rawLine.trim(),
|
|
985
|
-
commitHash: commit.hash,
|
|
986
|
-
commitDate: commit.date,
|
|
987
|
-
reason: rule.name
|
|
988
|
-
});
|
|
989
|
-
commitEscapes++;
|
|
990
|
-
break;
|
|
991
|
-
}
|
|
992
|
-
}
|
|
993
|
-
}
|
|
994
|
-
}
|
|
995
|
-
if (!isComment && commitSwallowed < MAX_INFRACTIONS_PER_COMMIT) {
|
|
996
|
-
for (const rule of SWALLOWED_ERROR_PATTERNS) {
|
|
997
|
-
if (rule.regex.test(cleanedLine)) {
|
|
998
|
-
this.swallowedErrors.push({
|
|
999
|
-
type: "swallowed-error",
|
|
1000
|
-
file: file.path,
|
|
1001
|
-
lineNumber: added.lineNumber,
|
|
1002
|
-
lineSnippet: rawLine.trim(),
|
|
1003
|
-
commitHash: commit.hash,
|
|
1004
|
-
commitDate: commit.date,
|
|
1005
|
-
reason: rule.name
|
|
1006
|
-
});
|
|
1007
|
-
commitSwallowed++;
|
|
1008
|
-
break;
|
|
1009
|
-
}
|
|
1010
|
-
}
|
|
1011
|
-
}
|
|
1012
|
-
if (!isComment && isTestFile(file.path) && commitTrivial < MAX_INFRACTIONS_PER_COMMIT) {
|
|
1013
|
-
for (const rule of TRIVIAL_ASSERT_PATTERNS) {
|
|
1014
|
-
if (rule.regex.test(cleanedLine)) {
|
|
1015
|
-
this.trivialAsserts.push({
|
|
1016
|
-
type: "trivial-assert",
|
|
1017
|
-
file: file.path,
|
|
1018
|
-
lineNumber: added.lineNumber,
|
|
1019
|
-
lineSnippet: rawLine.trim(),
|
|
1020
|
-
commitHash: commit.hash,
|
|
1021
|
-
commitDate: commit.date,
|
|
1022
|
-
reason: rule.name
|
|
1023
|
-
});
|
|
1024
|
-
commitTrivial++;
|
|
1025
|
-
break;
|
|
1026
|
-
}
|
|
1027
|
-
}
|
|
1028
|
-
}
|
|
1029
|
-
if (!isComment && commitStubs < MAX_INFRACTIONS_PER_COMMIT) {
|
|
1030
|
-
for (const rule of STUB_ABANDONMENT_PATTERNS) {
|
|
1031
|
-
if (rule.regex.test(rawLine)) {
|
|
1032
|
-
this.stubAbandonments.push({
|
|
1033
|
-
type: "stub-abandonment",
|
|
1034
|
-
file: file.path,
|
|
1035
|
-
lineNumber: added.lineNumber,
|
|
1036
|
-
lineSnippet: rawLine.trim(),
|
|
1037
|
-
commitHash: commit.hash,
|
|
1038
|
-
commitDate: commit.date,
|
|
1039
|
-
reason: rule.name
|
|
1040
|
-
});
|
|
1041
|
-
commitStubs++;
|
|
1042
|
-
break;
|
|
1043
|
-
}
|
|
1044
|
-
}
|
|
1045
|
-
}
|
|
1046
|
-
}
|
|
1170
|
+
const audit = auditFileDiffs(commit.files, commit.hash, commit.date, {
|
|
1171
|
+
ignorePaths: this.ignorePaths,
|
|
1172
|
+
rules: this.rules
|
|
1173
|
+
});
|
|
1174
|
+
const buckets = {
|
|
1175
|
+
"test-skip": this.testSkips,
|
|
1176
|
+
"type-escape": this.typeEscapes,
|
|
1177
|
+
"swallowed-error": this.swallowedErrors,
|
|
1178
|
+
"trivial-assert": this.trivialAsserts,
|
|
1179
|
+
"stub-abandonment": this.stubAbandonments,
|
|
1180
|
+
"panic-loop": this.panicLoops
|
|
1181
|
+
};
|
|
1182
|
+
const counts = {};
|
|
1183
|
+
for (const infraction of audit.infractions) {
|
|
1184
|
+
const count = counts[infraction.type] || 0;
|
|
1185
|
+
if (count < MAX_INFRACTIONS_PER_COMMIT) buckets[infraction.type].push(infraction);
|
|
1186
|
+
counts[infraction.type] = count + 1;
|
|
1047
1187
|
}
|
|
1048
1188
|
}
|
|
1049
1189
|
getSummary() {
|
|
@@ -1161,25 +1301,26 @@ function formatTerminalCard(summary, options) {
|
|
|
1161
1301
|
);
|
|
1162
1302
|
lines.push("");
|
|
1163
1303
|
if (!summary.isSufficientData || summary.score === null) {
|
|
1164
|
-
lines.push(pc.yellow(` insufficient data (< 200 lines added
|
|
1165
|
-
lines.push(pc.dim("
|
|
1304
|
+
lines.push(pc.yellow(` insufficient data (< 200 analyzed lines added)`));
|
|
1305
|
+
lines.push(pc.dim(" Findings below remain actionable; a score needs a larger sample."));
|
|
1306
|
+
lines.push("");
|
|
1307
|
+
}
|
|
1308
|
+
if (summary.isSufficientData && summary.score !== null) {
|
|
1309
|
+
let scoreColor = pc.green;
|
|
1310
|
+
if (summary.score < 60) scoreColor = pc.red;
|
|
1311
|
+
else if (summary.score < 80) scoreColor = pc.yellow;
|
|
1312
|
+
lines.push(
|
|
1313
|
+
` ${pc.bold("SCORE:")} ${scoreColor(pc.bold(`${summary.score} / 100`))} ${pc.dim("\xB7")} ${pc.magenta(pc.bold(summary.archetype))}`
|
|
1314
|
+
);
|
|
1315
|
+
lines.push(` ${pc.dim(summary.archetypeDescription)}`);
|
|
1166
1316
|
lines.push("");
|
|
1167
|
-
return lines.join("\n");
|
|
1168
1317
|
}
|
|
1169
|
-
let scoreColor = pc.green;
|
|
1170
|
-
if (summary.score < 60) scoreColor = pc.red;
|
|
1171
|
-
else if (summary.score < 80) scoreColor = pc.yellow;
|
|
1172
|
-
lines.push(
|
|
1173
|
-
` ${pc.bold("SCORE:")} ${scoreColor(pc.bold(`${summary.score} / 100`))} ${pc.dim("\xB7")} ${pc.magenta(pc.bold(summary.archetype))}`
|
|
1174
|
-
);
|
|
1175
|
-
lines.push(` ${pc.dim(summary.archetypeDescription)}`);
|
|
1176
|
-
lines.push("");
|
|
1177
1318
|
const totalInfractions = (summary.testSkips?.length || 0) + (summary.typeEscapes?.length || 0) + (summary.swallowedErrors?.length || 0) + (summary.panicLoops?.length || 0) + (summary.trivialAsserts?.length || 0) + (summary.stubAbandonments?.length || 0);
|
|
1178
1319
|
lines.push(pc.bold(` INFRACTIONS (${totalInfractions})`));
|
|
1179
1320
|
lines.push(pc.dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
1180
1321
|
if (summary.panicLoops && summary.panicLoops.length > 0) {
|
|
1181
1322
|
lines.push(
|
|
1182
|
-
` ${pc.magenta("\u25CF")} ${pc.bold("panic-loop")} (${summary.panicLoops.length}) ${pc.dim("consecutive
|
|
1323
|
+
` ${pc.magenta("\u25CF")} ${pc.bold("panic-loop")} (${summary.panicLoops.length}) ${pc.dim("rapid consecutive fixes touching overlapping files")}`
|
|
1183
1324
|
);
|
|
1184
1325
|
const sample = summary.panicLoops[0];
|
|
1185
1326
|
lines.push(` ${pc.dim(sample.reason)}`);
|
|
@@ -1278,10 +1419,10 @@ function formatPrTerminalCard(result) {
|
|
|
1278
1419
|
const lines = [];
|
|
1279
1420
|
lines.push("");
|
|
1280
1421
|
lines.push(
|
|
1281
|
-
`${pc.bold(pc.cyan("agent-roast"))} ${pc.dim("\u2014 pull request audit")} ${pc.white(`${result.baseSha.slice(0, 7)}...${result.headSha.slice(0, 7)}`)}`
|
|
1422
|
+
`${pc.bold(pc.cyan("agent-roast"))} ${pc.dim(result.mode === "staged" ? "\u2014 staged changes audit" : "\u2014 pull request audit")} ${pc.white(`${result.baseSha.slice(0, 7)}...${result.headSha.slice(0, 7)}`)}`
|
|
1282
1423
|
);
|
|
1283
1424
|
if (!result.hasSupportedChanges) {
|
|
1284
|
-
lines.push(` ${pc.dim("no supported source changes detected in
|
|
1425
|
+
lines.push(` ${pc.dim("no supported source changes detected in the audited diff.")}`);
|
|
1285
1426
|
lines.push("");
|
|
1286
1427
|
return lines.join("\n");
|
|
1287
1428
|
}
|
|
@@ -1290,7 +1431,7 @@ function formatPrTerminalCard(result) {
|
|
|
1290
1431
|
);
|
|
1291
1432
|
lines.push("");
|
|
1292
1433
|
if (result.infractions.length === 0) {
|
|
1293
|
-
lines.push(` ${pc.green("PASS")} ${pc.dim("zero infractions detected across all
|
|
1434
|
+
lines.push(` ${pc.green("PASS")} ${pc.dim("zero infractions detected across all audited changes.")}`);
|
|
1294
1435
|
} else {
|
|
1295
1436
|
lines.push(pc.bold(` INFRACTIONS DETECTED (${result.infractions.length})`));
|
|
1296
1437
|
lines.push(pc.dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
@@ -1302,6 +1443,7 @@ function formatPrTerminalCard(result) {
|
|
|
1302
1443
|
if (inf.lineSnippet) {
|
|
1303
1444
|
lines.push(` ${pc.dim(`\u2514\u2500 ${inf.lineSnippet}`)}`);
|
|
1304
1445
|
}
|
|
1446
|
+
if (inf.suggestion) lines.push(` ${pc.dim(`Next: ${inf.suggestion}`)}`);
|
|
1305
1447
|
}
|
|
1306
1448
|
}
|
|
1307
1449
|
lines.push("");
|
|
@@ -1328,6 +1470,10 @@ function sortInfractionsDeterministic(infractions) {
|
|
|
1328
1470
|
return (a.lineNumber || 0) - (b.lineNumber || 0);
|
|
1329
1471
|
});
|
|
1330
1472
|
}
|
|
1473
|
+
function escapeCommand(value, property = false) {
|
|
1474
|
+
const escaped = value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
|
|
1475
|
+
return property ? escaped.replace(/:/g, "%3A").replace(/,/g, "%2C") : escaped;
|
|
1476
|
+
}
|
|
1331
1477
|
function formatGithubAnnotations(result) {
|
|
1332
1478
|
const outputLines = [];
|
|
1333
1479
|
const sorted = sortInfractionsDeterministic(result.infractions);
|
|
@@ -1337,20 +1483,20 @@ function formatGithubAnnotations(result) {
|
|
|
1337
1483
|
let omittedCount = 0;
|
|
1338
1484
|
for (const inf of sorted) {
|
|
1339
1485
|
if (!inf.file || inf.file === "unknown") continue;
|
|
1340
|
-
const isError = result.failedRules.includes(inf.type)
|
|
1486
|
+
const isError = result.failedRules.includes(inf.type);
|
|
1341
1487
|
const line = inf.lineNumber || 1;
|
|
1342
|
-
const title = inf.reason
|
|
1343
|
-
const msg = (inf.lineSnippet || inf.reason)
|
|
1488
|
+
const title = escapeCommand(inf.reason, true);
|
|
1489
|
+
const msg = escapeCommand(inf.lineSnippet || inf.reason);
|
|
1344
1490
|
if (isError) {
|
|
1345
1491
|
if (errorCount < MAX_PER_LEVEL) {
|
|
1346
|
-
outputLines.push(`::error file=${inf.file},line=${line},title=${title}::${msg}`);
|
|
1492
|
+
outputLines.push(`::error file=${escapeCommand(inf.file, true)},line=${line},title=${title}::${msg}`);
|
|
1347
1493
|
errorCount++;
|
|
1348
1494
|
} else {
|
|
1349
1495
|
omittedCount++;
|
|
1350
1496
|
}
|
|
1351
1497
|
} else {
|
|
1352
1498
|
if (warnCount < MAX_PER_LEVEL) {
|
|
1353
|
-
outputLines.push(`::warning file=${inf.file},line=${line},title=${title}::${msg}`);
|
|
1499
|
+
outputLines.push(`::warning file=${escapeCommand(inf.file, true)},line=${line},title=${title}::${msg}`);
|
|
1354
1500
|
warnCount++;
|
|
1355
1501
|
} else {
|
|
1356
1502
|
omittedCount++;
|
|
@@ -1366,10 +1512,10 @@ function formatGithubAnnotations(result) {
|
|
|
1366
1512
|
}
|
|
1367
1513
|
function formatGithubStepSummary(result) {
|
|
1368
1514
|
const lines = [];
|
|
1369
|
-
lines.push("### agent-roast Pull Request Audit");
|
|
1515
|
+
lines.push(result.mode === "staged" ? "### agent-roast Staged Changes Audit" : "### agent-roast Pull Request Audit");
|
|
1370
1516
|
lines.push("");
|
|
1371
1517
|
if (!result.hasSupportedChanges) {
|
|
1372
|
-
lines.push("No supported source file changes detected in
|
|
1518
|
+
lines.push("No supported source file changes detected in the audited diff.");
|
|
1373
1519
|
return lines.join("\n");
|
|
1374
1520
|
}
|
|
1375
1521
|
const statusBadge = result.passed ? "Passed" : `Failed (${result.failedRules.join(", ")})`;
|
|
@@ -1404,7 +1550,7 @@ function formatPrMarkdownComment(result) {
|
|
|
1404
1550
|
lines.push(`## \u{1F6E1}\uFE0F agent-roast audit: ${titleStatus} ${titleEmoji}`);
|
|
1405
1551
|
lines.push("");
|
|
1406
1552
|
if (!result.hasSupportedChanges) {
|
|
1407
|
-
lines.push("No supported source file changes detected in
|
|
1553
|
+
lines.push("No supported source file changes detected in the audited diff.");
|
|
1408
1554
|
lines.push("");
|
|
1409
1555
|
lines.push("---");
|
|
1410
1556
|
lines.push("*Audited 100% offline with [agent-roast](https://github.com/Nari-Ab/agent-roast)*");
|
|
@@ -1440,22 +1586,106 @@ function formatPrMarkdownComment(result) {
|
|
|
1440
1586
|
lines.push("*Audited 100% offline with [agent-roast](https://github.com/Nari-Ab/agent-roast)*");
|
|
1441
1587
|
return lines.join("\n");
|
|
1442
1588
|
}
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
];
|
|
1589
|
+
function terminalText(value) {
|
|
1590
|
+
return value.replace(/[\u0000-\u001f\u007f-\u009f]/g, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
1591
|
+
}
|
|
1592
|
+
function formatCheckTerminal(result) {
|
|
1593
|
+
const lines = [
|
|
1594
|
+
"",
|
|
1595
|
+
pc.bold("agent-roast \u2014 current code check"),
|
|
1596
|
+
` ${result.filesInspected} files \xB7 ${result.linesInspected} lines inspected`,
|
|
1597
|
+
` ${result.infractions.length} findings to review \xB7 ${result.baselineCount} accepted baseline findings`
|
|
1598
|
+
];
|
|
1599
|
+
if (result.staleBaselineCount) lines.push(` ${result.staleBaselineCount} baseline findings no longer present (or now disabled/ignored).`);
|
|
1600
|
+
for (const inf of result.infractions.slice(0, 20)) {
|
|
1601
|
+
lines.push("", ` ${result.failedRules.includes(inf.type) ? pc.red("FAIL") : pc.yellow("WARN")} [${inf.type}] ${terminalText(inf.file || "unknown")}:${inf.lineNumber}`, ` ${terminalText(inf.lineSnippet || inf.reason)}`);
|
|
1602
|
+
if (inf.suggestion) lines.push(` Next: ${inf.suggestion}`);
|
|
1603
|
+
}
|
|
1604
|
+
if (result.infractions.length > 20) lines.push(`
|
|
1605
|
+
${result.infractions.length - 20} more findings. Use --format json for the complete report.`);
|
|
1606
|
+
for (const skipped of result.skippedFiles) lines.push(`
|
|
1607
|
+
NOT SCANNED ${terminalText(skipped.file)}: ${terminalText(skipped.reason)}`);
|
|
1608
|
+
lines.push("", !result.complete ? pc.red(" INCOMPLETE: some source could not be inspected; exit 2.") : result.passed ? pc.green(" PASSED: no new blocking findings.") : pc.red(` BLOCKED: ${result.failedRules.join(", ")}.`), "");
|
|
1609
|
+
if (!result.filesInspected) lines.push(" No supported source files inspected.", "");
|
|
1610
|
+
return lines.join("\n");
|
|
1611
|
+
}
|
|
1612
|
+
function markdownCell(value) {
|
|
1613
|
+
return value.replace(/[\\[\]()*_!]/g, "\\$&").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/\|/g, "\\|").replace(/`/g, "`").replace(/[\r\n]/g, " ");
|
|
1614
|
+
}
|
|
1615
|
+
function formatCheckMarkdown(result) {
|
|
1616
|
+
const status = !result.complete ? "INCOMPLETE" : result.passed ? "PASSED" : "BLOCKED";
|
|
1617
|
+
const lines = [
|
|
1618
|
+
`## agent-roast current code check: ${status}`,
|
|
1619
|
+
"",
|
|
1620
|
+
`${result.filesInspected} files, ${result.linesInspected} lines inspected. ${result.infractions.length} findings to review; ${result.baselineCount} accepted baseline findings.`,
|
|
1621
|
+
"",
|
|
1622
|
+
"| Rule | Location | Finding | Next step |",
|
|
1623
|
+
"| --- | --- | --- | --- |"
|
|
1624
|
+
];
|
|
1625
|
+
for (const inf of result.infractions) lines.push(`| ${inf.type} | ${markdownCell(`${inf.file}:${inf.lineNumber}`)} | ${markdownCell(inf.lineSnippet || inf.reason)} | ${markdownCell(inf.suggestion || "Review this occurrence.")} |`);
|
|
1626
|
+
if (result.staleBaselineCount) lines.push("", `${result.staleBaselineCount} baseline findings no longer present (or now disabled/ignored).`);
|
|
1627
|
+
for (const skipped of result.skippedFiles) lines.push("", `Not scanned: ${markdownCell(skipped.file)} \u2014 ${markdownCell(skipped.reason)}`);
|
|
1628
|
+
return lines.join("\n");
|
|
1629
|
+
}
|
|
1630
|
+
function formatReviewTerminal(result) {
|
|
1631
|
+
const status = !result.complete ? "INCOMPLETE" : result.passed ? "PASSED" : "BLOCKED";
|
|
1632
|
+
const lines = [
|
|
1633
|
+
"",
|
|
1634
|
+
pc.bold(`agent-roast \u2014 context review: ${status}`),
|
|
1635
|
+
` ${result.mergeBaseSha.slice(0, 7)} \u2192 ${result.headSha.slice(0, 7)} \xB7 policy from base ${result.policySha.slice(0, 7)}`,
|
|
1636
|
+
` ${result.filesInspected} changed files compared \xB7 ${result.linesInspected} source lines inspected across both versions`,
|
|
1637
|
+
` ${result.infractions.length} new \xB7 ${result.unchangedCount} existing \xB7 ${result.removedFindings.length} no longer detected`
|
|
1638
|
+
];
|
|
1639
|
+
if (result.policyChanges.length) lines.push(" Candidate policy changes were ignored; adopt them separately after review.");
|
|
1640
|
+
if (result.excludedFiles || result.unsupportedFiles) lines.push(` ${result.excludedFiles} excluded \xB7 ${result.unsupportedFiles} unsupported/non-source changes`);
|
|
1641
|
+
for (const finding of result.infractions.slice(0, 20)) {
|
|
1642
|
+
lines.push(
|
|
1643
|
+
"",
|
|
1644
|
+
` ${result.failedRules.includes(finding.type) ? "FAIL" : "WARN"} [${finding.type}] ${terminalText(finding.file || "unknown")}:${finding.lineNumber}`,
|
|
1645
|
+
` ${terminalText(finding.lineSnippet || finding.reason)}`,
|
|
1646
|
+
` Next: ${finding.suggestion || "Review this occurrence."}`
|
|
1647
|
+
);
|
|
1648
|
+
}
|
|
1649
|
+
if (result.infractions.length > 20) lines.push(` ${result.infractions.length - 20} more new findings; use --format json for all results.`);
|
|
1650
|
+
for (const skipped of result.skippedFiles) lines.push(` NOT COMPARED ${terminalText(skipped.file)}: ${terminalText(skipped.reason)}`);
|
|
1651
|
+
lines.push("", !result.complete ? " INCOMPLETE: some changed source was not compared (exit 2)." : result.passed ? " PASSED: no new blocking findings." : ` BLOCKED: ${result.failedRules.join(", ")}.`, "");
|
|
1652
|
+
if (!result.filesInspected) lines.push(" No supported changed source files compared.", "");
|
|
1653
|
+
return lines.join("\n");
|
|
1654
|
+
}
|
|
1655
|
+
function formatReviewMarkdown(result) {
|
|
1656
|
+
const status = !result.complete ? "INCOMPLETE" : result.passed ? "PASSED" : "BLOCKED";
|
|
1657
|
+
const lines = [
|
|
1658
|
+
`## agent-roast context review: ${status}`,
|
|
1659
|
+
"",
|
|
1660
|
+
`Compared ${result.filesInspected} changed files at \`${result.mergeBaseSha.slice(0, 7)}\` \u2192 \`${result.headSha.slice(0, 7)}\`. Policy: \`${result.policySha.slice(0, 7)}\`.`,
|
|
1661
|
+
"",
|
|
1662
|
+
`**${result.infractions.length} new** \xB7 ${result.unchangedCount} existing \xB7 ${result.removedFindings.length} no longer detected.`,
|
|
1663
|
+
""
|
|
1664
|
+
];
|
|
1665
|
+
if (result.policyChanges.length) lines.push("Candidate policy changes were ignored. This review uses the supplied base commit's policy.", "");
|
|
1666
|
+
lines.push("| New finding | Head location | Evidence | Next step |", "| --- | --- | --- | --- |");
|
|
1667
|
+
for (const finding of result.infractions) lines.push(`| ${finding.type} | ${markdownCell(`${finding.file}:${finding.lineNumber}`)} | ${markdownCell(finding.lineSnippet || finding.reason)} | ${markdownCell(finding.suggestion || "Review this occurrence.")} |`);
|
|
1668
|
+
if (result.removedFindings.length) {
|
|
1669
|
+
lines.push(
|
|
1670
|
+
"",
|
|
1671
|
+
"### No longer detected",
|
|
1672
|
+
"",
|
|
1673
|
+
"These patterns disappeared from the compared source. Deleting code is not proof of a behavioral fix.",
|
|
1674
|
+
"",
|
|
1675
|
+
"| Rule | Previous location | Previous evidence |",
|
|
1676
|
+
"| --- | --- | --- |"
|
|
1677
|
+
);
|
|
1678
|
+
for (const finding of result.removedFindings) lines.push(`| ${finding.type} | ${markdownCell(`${finding.file}:${finding.lineNumber}`)} | ${markdownCell(finding.lineSnippet || finding.reason)} |`);
|
|
1679
|
+
}
|
|
1680
|
+
for (const skipped of result.skippedFiles) lines.push("", `Not compared: ${markdownCell(skipped.file)} \u2014 ${markdownCell(skipped.reason)}`);
|
|
1681
|
+
lines.push("", `Scope: changed supported files only; ${result.excludedFiles} excluded and ${result.unsupportedFiles} unsupported/non-source changes.`);
|
|
1682
|
+
return lines.join("\n");
|
|
1683
|
+
}
|
|
1453
1684
|
|
|
1454
1685
|
// src/init.ts
|
|
1455
1686
|
import * as fs2 from "fs";
|
|
1456
1687
|
import * as path2 from "path";
|
|
1457
1688
|
var DEFAULT_CONFIG_TEMPLATE = {
|
|
1458
|
-
$schema: "https://raw.githubusercontent.com/Nari-Ab/agent-roast/main/schema.json",
|
|
1459
1689
|
ignorePaths: [
|
|
1460
1690
|
"**/*.min.js",
|
|
1461
1691
|
"**/vendor/**",
|
|
@@ -1463,21 +1693,16 @@ var DEFAULT_CONFIG_TEMPLATE = {
|
|
|
1463
1693
|
"**/build/**",
|
|
1464
1694
|
"**/fixtures/**"
|
|
1465
1695
|
],
|
|
1466
|
-
rules: {
|
|
1467
|
-
"hallucinated-package": "error",
|
|
1468
|
-
"empty-catch": "warn",
|
|
1469
|
-
"trivial-assert": "warn",
|
|
1470
|
-
"stub-abandonment": "warn",
|
|
1471
|
-
"mock-leak": "warn"
|
|
1472
|
-
},
|
|
1696
|
+
rules: { ...DEFAULT_CONFIG.rules },
|
|
1473
1697
|
scoreThreshold: 70
|
|
1474
1698
|
};
|
|
1475
1699
|
var GITHUB_WORKFLOW_TEMPLATE = `name: Agent Roast
|
|
1476
1700
|
on:
|
|
1477
1701
|
pull_request:
|
|
1478
1702
|
branches: [main, master]
|
|
1479
|
-
|
|
1480
|
-
|
|
1703
|
+
|
|
1704
|
+
permissions:
|
|
1705
|
+
contents: read
|
|
1481
1706
|
|
|
1482
1707
|
jobs:
|
|
1483
1708
|
audit:
|
|
@@ -1489,22 +1714,18 @@ jobs:
|
|
|
1489
1714
|
with:
|
|
1490
1715
|
fetch-depth: 0
|
|
1491
1716
|
|
|
1492
|
-
- name: Setup Node.js
|
|
1493
|
-
uses: actions/setup-node@v4
|
|
1494
|
-
with:
|
|
1495
|
-
node-version: 20
|
|
1496
|
-
|
|
1497
1717
|
- name: Run agent-roast PR check
|
|
1498
1718
|
if: github.event_name == 'pull_request'
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1719
|
+
uses: Nari-Ab/agent-roast@v1
|
|
1720
|
+
with:
|
|
1721
|
+
base: \${{ github.event.pull_request.base.sha }}
|
|
1722
|
+
head: \${{ github.event.pull_request.head.sha }}
|
|
1723
|
+
format: github
|
|
1724
|
+
mode: review
|
|
1504
1725
|
`;
|
|
1505
1726
|
var PRE_COMMIT_HOOK_TEMPLATE = `#!/bin/sh
|
|
1506
1727
|
# Run agent-roast pre-commit verification
|
|
1507
|
-
npx agent-roast --
|
|
1728
|
+
npx --no-install agent-roast --staged
|
|
1508
1729
|
`;
|
|
1509
1730
|
async function initProject(options = {}) {
|
|
1510
1731
|
const cwd = path2.resolve(options.cwd || process.cwd());
|
|
@@ -1512,7 +1733,7 @@ async function initProject(options = {}) {
|
|
|
1512
1733
|
const skipped = [];
|
|
1513
1734
|
const configPath = path2.join(cwd, ".agentroastrc.json");
|
|
1514
1735
|
const altConfigPath = path2.join(cwd, "agent-roast.config.json");
|
|
1515
|
-
if (fs2.existsSync(configPath) || fs2.existsSync(altConfigPath)) {
|
|
1736
|
+
if (fs2.existsSync(configPath) || fs2.existsSync(altConfigPath) || fs2.existsSync(path2.join(cwd, ".agentroastrc"))) {
|
|
1516
1737
|
skipped.push(".agentroastrc.json");
|
|
1517
1738
|
} else {
|
|
1518
1739
|
fs2.writeFileSync(
|
|
@@ -1613,6 +1834,539 @@ function generateShieldsJson(score, label = "AI Discipline") {
|
|
|
1613
1834
|
};
|
|
1614
1835
|
}
|
|
1615
1836
|
|
|
1837
|
+
// src/sarif.ts
|
|
1838
|
+
import { createRequire } from "module";
|
|
1839
|
+
|
|
1840
|
+
// src/baseline.ts
|
|
1841
|
+
import { createHash, randomUUID } from "crypto";
|
|
1842
|
+
import * as fs3 from "fs";
|
|
1843
|
+
import path3 from "path";
|
|
1844
|
+
function findingFingerprint(finding) {
|
|
1845
|
+
return createHash("sha256").update(JSON.stringify([finding.type, finding.file, finding.lineSnippet?.trim()])).digest("hex");
|
|
1846
|
+
}
|
|
1847
|
+
function createBaseline(findings) {
|
|
1848
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1849
|
+
for (const finding of findings) {
|
|
1850
|
+
const fingerprint = findingFingerprint(finding);
|
|
1851
|
+
counts.set(fingerprint, (counts.get(fingerprint) || 0) + 1);
|
|
1852
|
+
}
|
|
1853
|
+
return {
|
|
1854
|
+
schemaVersion: "1.0.0",
|
|
1855
|
+
fingerprintVersion: 1,
|
|
1856
|
+
entries: [...counts].sort(([a], [b]) => a.localeCompare(b)).map(([fingerprint, count]) => ({ fingerprint, count }))
|
|
1857
|
+
};
|
|
1858
|
+
}
|
|
1859
|
+
function loadBaseline(filename) {
|
|
1860
|
+
try {
|
|
1861
|
+
const stat = fs3.lstatSync(filename);
|
|
1862
|
+
if (!stat.isFile() || stat.size > 5 * 1024 * 1024) throw new Error("Expected a regular baseline file under 5 MiB.");
|
|
1863
|
+
const fd = fs3.openSync(filename, fs3.constants.O_RDONLY | (fs3.constants.O_NOFOLLOW || 0));
|
|
1864
|
+
let content;
|
|
1865
|
+
try {
|
|
1866
|
+
const opened = fs3.fstatSync(fd);
|
|
1867
|
+
if (opened.ino !== stat.ino || opened.dev !== stat.dev) throw new Error("Baseline changed while opening.");
|
|
1868
|
+
const buffer = Buffer.alloc(5 * 1024 * 1024 + 1);
|
|
1869
|
+
let size = 0;
|
|
1870
|
+
while (size < buffer.length) {
|
|
1871
|
+
const read = fs3.readSync(fd, buffer, size, buffer.length - size, null);
|
|
1872
|
+
if (!read) break;
|
|
1873
|
+
size += read;
|
|
1874
|
+
}
|
|
1875
|
+
const after = fs3.fstatSync(fd);
|
|
1876
|
+
if (size > 5 * 1024 * 1024 || size !== after.size || opened.mtimeMs !== after.mtimeMs) throw new Error("Baseline changed or exceeded the size limit.");
|
|
1877
|
+
content = buffer.subarray(0, size).toString("utf8");
|
|
1878
|
+
} finally {
|
|
1879
|
+
fs3.closeSync(fd);
|
|
1880
|
+
}
|
|
1881
|
+
const parsed = JSON.parse(content);
|
|
1882
|
+
if (!parsed || parsed.schemaVersion !== "1.0.0" || parsed.fingerprintVersion !== 1 || !Array.isArray(parsed.entries) || parsed.entries.length > 1e5) {
|
|
1883
|
+
throw new Error("Unsupported baseline schema.");
|
|
1884
|
+
}
|
|
1885
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1886
|
+
for (const entry of parsed.entries) {
|
|
1887
|
+
if (!entry || typeof entry.fingerprint !== "string" || !/^[a-f0-9]{64}$/.test(entry.fingerprint) || !Number.isSafeInteger(entry.count) || entry.count <= 0 || entry.count > 1e6 || seen.has(entry.fingerprint)) {
|
|
1888
|
+
throw new Error("Invalid or duplicate baseline entry.");
|
|
1889
|
+
}
|
|
1890
|
+
seen.add(entry.fingerprint);
|
|
1891
|
+
}
|
|
1892
|
+
return parsed;
|
|
1893
|
+
} catch (error) {
|
|
1894
|
+
throw new ConfigError(`Cannot load baseline '${filename}': ${error instanceof Error ? error.message : String(error)}`);
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
function applyBaseline(findings, baseline) {
|
|
1898
|
+
const remaining = new Map(baseline.entries.map((entry) => [entry.fingerprint, entry.count]));
|
|
1899
|
+
const fresh = [];
|
|
1900
|
+
let baselineCount = 0;
|
|
1901
|
+
for (const finding of findings) {
|
|
1902
|
+
const fingerprint = findingFingerprint(finding);
|
|
1903
|
+
const count = remaining.get(fingerprint) || 0;
|
|
1904
|
+
if (count > 0) {
|
|
1905
|
+
remaining.set(fingerprint, count - 1);
|
|
1906
|
+
baselineCount++;
|
|
1907
|
+
} else {
|
|
1908
|
+
fresh.push(finding);
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
return { findings: fresh, baselineCount, staleBaselineCount: [...remaining.values()].reduce((a, b) => a + b, 0) };
|
|
1912
|
+
}
|
|
1913
|
+
function writeBaseline(filename, findings) {
|
|
1914
|
+
const destination = path3.resolve(filename);
|
|
1915
|
+
const temporary = path3.join(path3.dirname(destination), `.agent-roast-${randomUUID()}.tmp`);
|
|
1916
|
+
let fd;
|
|
1917
|
+
try {
|
|
1918
|
+
fd = fs3.openSync(temporary, "wx", 384);
|
|
1919
|
+
fs3.writeFileSync(fd, JSON.stringify(createBaseline(findings), null, 2) + "\n");
|
|
1920
|
+
fs3.fsyncSync(fd);
|
|
1921
|
+
fs3.closeSync(fd);
|
|
1922
|
+
fd = void 0;
|
|
1923
|
+
fs3.linkSync(temporary, destination);
|
|
1924
|
+
} catch (error) {
|
|
1925
|
+
throw new ConfigError(`Cannot write baseline '${filename}' (existing files are preserved): ${error instanceof Error ? error.message : String(error)}`);
|
|
1926
|
+
} finally {
|
|
1927
|
+
if (fd !== void 0) fs3.closeSync(fd);
|
|
1928
|
+
fs3.rmSync(temporary, { force: true });
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
// src/sarif.ts
|
|
1933
|
+
var require2 = createRequire(import.meta.url);
|
|
1934
|
+
var version = require2("../package.json").version;
|
|
1935
|
+
function formatSarif(report) {
|
|
1936
|
+
const occurrences = /* @__PURE__ */ new Map();
|
|
1937
|
+
const results = report.infractions.map((finding) => {
|
|
1938
|
+
const filename = finding.file;
|
|
1939
|
+
if (!filename || filename.startsWith("/") || filename.split("/").includes("..") || /^[a-z]:/i.test(filename)) {
|
|
1940
|
+
throw new ConfigError("SARIF needs repository-relative finding paths.");
|
|
1941
|
+
}
|
|
1942
|
+
const fingerprint = findingFingerprint(finding);
|
|
1943
|
+
const occurrence = (occurrences.get(fingerprint) || 0) + 1;
|
|
1944
|
+
occurrences.set(fingerprint, occurrence);
|
|
1945
|
+
return {
|
|
1946
|
+
ruleId: finding.type,
|
|
1947
|
+
ruleIndex: VALID_DETECTORS.indexOf(finding.type),
|
|
1948
|
+
level: report.failedRules.includes(finding.type) ? "error" : "warning",
|
|
1949
|
+
message: { text: `${finding.reason}. ${finding.suggestion || RULE_GUIDES[finding.type].suggestion}` },
|
|
1950
|
+
locations: [{ physicalLocation: {
|
|
1951
|
+
artifactLocation: { uri: filename.split("/").map((segment) => encodeURIComponent(segment)).join("/") },
|
|
1952
|
+
region: { startLine: Math.max(1, finding.lineNumber || 1) }
|
|
1953
|
+
} }],
|
|
1954
|
+
partialFingerprints: { "agentRoastFinding/v1": `${fingerprint}:${occurrence}` },
|
|
1955
|
+
...report.mode === "review" ? { baselineState: "new" } : {}
|
|
1956
|
+
};
|
|
1957
|
+
});
|
|
1958
|
+
return JSON.stringify({
|
|
1959
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
1960
|
+
version: "2.1.0",
|
|
1961
|
+
runs: [{
|
|
1962
|
+
tool: { driver: {
|
|
1963
|
+
name: "agent-roast",
|
|
1964
|
+
semanticVersion: version,
|
|
1965
|
+
informationUri: "https://github.com/Nari-Ab/agent-roast",
|
|
1966
|
+
rules: VALID_DETECTORS.map((rule) => ({
|
|
1967
|
+
id: rule,
|
|
1968
|
+
name: rule,
|
|
1969
|
+
shortDescription: { text: RULE_GUIDES[rule].title },
|
|
1970
|
+
fullDescription: { text: RULE_GUIDES[rule].why },
|
|
1971
|
+
help: { text: `${RULE_GUIDES[rule].suggestion}
|
|
1972
|
+
Example: ${RULE_GUIDES[rule].example}` },
|
|
1973
|
+
defaultConfiguration: { level: "warning" },
|
|
1974
|
+
properties: { tags: ["maintainability", "code-review"] }
|
|
1975
|
+
}))
|
|
1976
|
+
} },
|
|
1977
|
+
invocations: [{
|
|
1978
|
+
executionSuccessful: report.complete,
|
|
1979
|
+
exitCode: report.exitCode,
|
|
1980
|
+
toolExecutionNotifications: report.skippedFiles.map((skipped) => ({
|
|
1981
|
+
level: "error",
|
|
1982
|
+
message: { text: `Not inspected: ${skipped.file}. ${skipped.reason}` }
|
|
1983
|
+
}))
|
|
1984
|
+
}],
|
|
1985
|
+
results,
|
|
1986
|
+
properties: report.mode === "review" ? {
|
|
1987
|
+
scope: "introduced findings in changed files",
|
|
1988
|
+
baseSha: report.baseSha,
|
|
1989
|
+
headSha: report.headSha,
|
|
1990
|
+
mergeBaseSha: report.mergeBaseSha,
|
|
1991
|
+
policySha: report.policySha,
|
|
1992
|
+
unchangedFindings: report.unchangedCount,
|
|
1993
|
+
removedFindings: report.removedFindings.length
|
|
1994
|
+
} : { scope: "current source", acceptedBaselineFindings: report.baselineCount }
|
|
1995
|
+
}]
|
|
1996
|
+
}, null, 2);
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
// src/check.ts
|
|
2000
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
2001
|
+
import * as fs4 from "fs/promises";
|
|
2002
|
+
import { constants as constants2 } from "fs";
|
|
2003
|
+
import path4 from "path";
|
|
2004
|
+
var MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
2005
|
+
var MAX_SCAN_BYTES = 50 * 1024 * 1024;
|
|
2006
|
+
var GENERATED_PATHS = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/vendor/**", "**/*.min.js", "**/*.map"];
|
|
2007
|
+
function git(cwd, args) {
|
|
2008
|
+
const result = spawnSync2("git", [...HERMETIC_GIT_FLAGS, ...args], {
|
|
2009
|
+
cwd,
|
|
2010
|
+
env: getHermeticGitEnv(),
|
|
2011
|
+
encoding: "utf8",
|
|
2012
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
2013
|
+
windowsHide: true
|
|
2014
|
+
});
|
|
2015
|
+
if (result.error || result.status !== 0) throw new GitError(result.error?.message || result.stderr, result.status);
|
|
2016
|
+
return result.stdout;
|
|
2017
|
+
}
|
|
2018
|
+
function checkAborted(signal) {
|
|
2019
|
+
if (signal?.aborted) throw new ConfigError("Project check cancelled; no complete result was produced.");
|
|
2020
|
+
}
|
|
2021
|
+
function insideRoot(root, filename) {
|
|
2022
|
+
const relative = path4.relative(root, filename);
|
|
2023
|
+
return relative.length > 0 && relative !== ".." && !relative.startsWith(".." + path4.sep) && !path4.isAbsolute(relative);
|
|
2024
|
+
}
|
|
2025
|
+
async function readSource(root, file, signal) {
|
|
2026
|
+
const filename = path4.resolve(root, file);
|
|
2027
|
+
if (!insideRoot(root, filename)) throw new Error("File escapes the repository.");
|
|
2028
|
+
let stat;
|
|
2029
|
+
try {
|
|
2030
|
+
stat = await fs4.lstat(filename);
|
|
2031
|
+
} catch (error) {
|
|
2032
|
+
if (error.code === "ENOENT") return null;
|
|
2033
|
+
throw error;
|
|
2034
|
+
}
|
|
2035
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("Symlinks and special files are not scanned.");
|
|
2036
|
+
const real = await fs4.realpath(filename);
|
|
2037
|
+
if (!insideRoot(root, real)) throw new Error("File resolves outside the repository.");
|
|
2038
|
+
if (stat.size > MAX_FILE_BYTES) throw new Error("File exceeds the 2 MiB scan limit.");
|
|
2039
|
+
const handle = await fs4.open(filename, constants2.O_RDONLY | (constants2.O_NOFOLLOW || 0));
|
|
2040
|
+
try {
|
|
2041
|
+
const opened = await handle.stat();
|
|
2042
|
+
if (opened.ino !== stat.ino || opened.dev !== stat.dev || !opened.isFile()) throw new Error("File changed while opening.");
|
|
2043
|
+
const chunks = [];
|
|
2044
|
+
let size = 0;
|
|
2045
|
+
while (true) {
|
|
2046
|
+
checkAborted(signal);
|
|
2047
|
+
const chunk = Buffer.alloc(Math.min(64 * 1024, MAX_FILE_BYTES + 1 - size));
|
|
2048
|
+
const { bytesRead } = await handle.read(chunk, 0, chunk.length, null);
|
|
2049
|
+
if (!bytesRead) break;
|
|
2050
|
+
size += bytesRead;
|
|
2051
|
+
if (size > MAX_FILE_BYTES) throw new Error("File exceeds the 2 MiB scan limit.");
|
|
2052
|
+
chunks.push(chunk.subarray(0, bytesRead));
|
|
2053
|
+
}
|
|
2054
|
+
const after = await handle.stat();
|
|
2055
|
+
if (after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || size !== after.size) throw new Error("File changed while reading; rerun the check.");
|
|
2056
|
+
return Buffer.concat(chunks);
|
|
2057
|
+
} finally {
|
|
2058
|
+
await handle.close();
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
async function checkProject(options = {}) {
|
|
2062
|
+
checkAborted(options.signal);
|
|
2063
|
+
const cwd = path4.resolve(options.cwd || process.cwd());
|
|
2064
|
+
const root = await fs4.realpath(git(cwd, ["rev-parse", "--show-toplevel"]).trim());
|
|
2065
|
+
const config = loadConfig(root);
|
|
2066
|
+
const failOn = options.failOn ?? config.failOn ?? ["test-skip", "trivial-assert"];
|
|
2067
|
+
for (const rule of failOn) if (!VALID_DETECTORS.includes(rule)) throw new ConfigError(`Unknown rule '${rule}'.`);
|
|
2068
|
+
const format = options.format || "terminal";
|
|
2069
|
+
if (!["terminal", "json", "github", "markdown", "sarif"].includes(format)) throw new ConfigError("Unsupported check output format.");
|
|
2070
|
+
const baseline = options.baseline ? loadBaseline(path4.resolve(cwd, options.baseline)) : void 0;
|
|
2071
|
+
const filenames = [...new Set(git(root, ["ls-files", "--cached", "--others", "--exclude-standard", "-z"]).split("\0").filter(Boolean))].sort();
|
|
2072
|
+
const infractions = [];
|
|
2073
|
+
const skippedFiles = [];
|
|
2074
|
+
let filesInspected = 0;
|
|
2075
|
+
let linesInspected = 0;
|
|
2076
|
+
let totalBytes = 0;
|
|
2077
|
+
for (const file of filenames) {
|
|
2078
|
+
checkAborted(options.signal);
|
|
2079
|
+
if (!isCodeFile(file, [...GENERATED_PATHS, ...config.ignorePaths || []])) continue;
|
|
2080
|
+
try {
|
|
2081
|
+
const content = await readSource(root, file, options.signal);
|
|
2082
|
+
if (content === null) continue;
|
|
2083
|
+
totalBytes += content.length;
|
|
2084
|
+
if (totalBytes > MAX_SCAN_BYTES) throw new Error("Project exceeds the 50 MiB scan limit.");
|
|
2085
|
+
if (content.includes(0)) throw new Error("Binary content in a source file.");
|
|
2086
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(content);
|
|
2087
|
+
const lines = text.split(/\r?\n/);
|
|
2088
|
+
if (lines[lines.length - 1] === "") lines.pop();
|
|
2089
|
+
const audit = auditFileDiffs([{ path: file, addedLines: lines.map((line, index) => ({ line, lineNumber: index + 1 })) }], "WORKTREE", "", { rules: config.rules });
|
|
2090
|
+
infractions.push(...audit.infractions);
|
|
2091
|
+
filesInspected++;
|
|
2092
|
+
linesInspected += lines.length;
|
|
2093
|
+
} catch (error) {
|
|
2094
|
+
checkAborted(options.signal);
|
|
2095
|
+
skippedFiles.push({ file, reason: error instanceof Error ? error.message : String(error) });
|
|
2096
|
+
if (totalBytes > MAX_SCAN_BYTES) break;
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
checkAborted(options.signal);
|
|
2100
|
+
const applied = baseline ? applyBaseline(infractions, baseline) : { findings: infractions, baselineCount: 0, staleBaselineCount: 0 };
|
|
2101
|
+
const findings = applied.findings;
|
|
2102
|
+
const failedRules = [...new Set(findings.filter((finding) => failOn.includes(finding.type)).map((finding) => finding.type))];
|
|
2103
|
+
const complete = skippedFiles.length === 0;
|
|
2104
|
+
const passed = complete && failedRules.length === 0;
|
|
2105
|
+
const result = {
|
|
2106
|
+
schemaVersion: "1.0.0",
|
|
2107
|
+
mode: "check",
|
|
2108
|
+
filesInspected,
|
|
2109
|
+
linesInspected,
|
|
2110
|
+
totalFindings: infractions.length,
|
|
2111
|
+
infractions: findings,
|
|
2112
|
+
baselineCount: applied.baselineCount,
|
|
2113
|
+
staleBaselineCount: applied.staleBaselineCount,
|
|
2114
|
+
skippedFiles,
|
|
2115
|
+
complete,
|
|
2116
|
+
failedRules,
|
|
2117
|
+
passed,
|
|
2118
|
+
exitCode: complete ? passed ? 0 : 1 : 2,
|
|
2119
|
+
output: ""
|
|
2120
|
+
};
|
|
2121
|
+
if (format === "json") result.output = JSON.stringify(result, null, 2);
|
|
2122
|
+
else if (format === "sarif") result.output = formatSarif(result);
|
|
2123
|
+
else if (format === "markdown") result.output = formatCheckMarkdown(result);
|
|
2124
|
+
else if (format === "github") {
|
|
2125
|
+
result.output = formatGithubAnnotations(result) + (complete ? "" : "\n::error::Project scan incomplete. See the JSON or terminal report for skipped files.");
|
|
2126
|
+
} else result.output = formatCheckTerminal(result);
|
|
2127
|
+
return result;
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
// src/review.ts
|
|
2131
|
+
import path5 from "path";
|
|
2132
|
+
|
|
2133
|
+
// src/snapshot.ts
|
|
2134
|
+
import { spawn as spawn2 } from "child_process";
|
|
2135
|
+
function assertNotAborted(signal) {
|
|
2136
|
+
if (signal?.aborted) throw new ConfigError("Review cancelled; no complete result was produced.");
|
|
2137
|
+
}
|
|
2138
|
+
async function readGit(cwd, args, signal, limit = 8 * 1024 * 1024) {
|
|
2139
|
+
assertNotAborted(signal);
|
|
2140
|
+
const env = getHermeticGitEnv({ GIT_NO_REPLACE_OBJECTS: "1", GIT_NO_LAZY_FETCH: "1", GIT_TERMINAL_PROMPT: "0" });
|
|
2141
|
+
for (const key of ["GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_NAMESPACE", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES"]) delete env[key];
|
|
2142
|
+
return new Promise((resolve2, reject) => {
|
|
2143
|
+
const child = spawn2("git", [...HERMETIC_GIT_FLAGS, ...args], {
|
|
2144
|
+
cwd,
|
|
2145
|
+
env,
|
|
2146
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
2147
|
+
windowsHide: true
|
|
2148
|
+
});
|
|
2149
|
+
const chunks = [];
|
|
2150
|
+
let bytes = 0;
|
|
2151
|
+
let stderr = "";
|
|
2152
|
+
let settled = false;
|
|
2153
|
+
const finish = (error) => {
|
|
2154
|
+
if (settled) return;
|
|
2155
|
+
settled = true;
|
|
2156
|
+
clearTimeout(timer);
|
|
2157
|
+
signal?.removeEventListener("abort", abort);
|
|
2158
|
+
if (error) {
|
|
2159
|
+
child.kill();
|
|
2160
|
+
reject(error);
|
|
2161
|
+
} else resolve2(Buffer.concat(chunks));
|
|
2162
|
+
};
|
|
2163
|
+
const abort = () => finish(new ConfigError("Review cancelled; no complete result was produced."));
|
|
2164
|
+
const timer = setTimeout(() => finish(new ConfigError("Git review read timed out after 30 seconds.")), 3e4);
|
|
2165
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
2166
|
+
child.stdout.on("data", (chunk) => {
|
|
2167
|
+
if (settled) return;
|
|
2168
|
+
bytes += chunk.length;
|
|
2169
|
+
if (bytes > limit) finish(new ConfigError(`Git read exceeds the ${limit} byte limit.`));
|
|
2170
|
+
else chunks.push(chunk);
|
|
2171
|
+
});
|
|
2172
|
+
child.stderr.on("data", (chunk) => {
|
|
2173
|
+
if (stderr.length < 64 * 1024) stderr += chunk.toString("utf8").slice(0, 64 * 1024 - stderr.length);
|
|
2174
|
+
});
|
|
2175
|
+
child.on("error", (error) => finish(error));
|
|
2176
|
+
child.on("close", (code) => finish(code === 0 ? void 0 : new GitError(stderr, code)));
|
|
2177
|
+
if (signal?.aborted) abort();
|
|
2178
|
+
});
|
|
2179
|
+
}
|
|
2180
|
+
async function reviewRefs(cwd, base, head, signal) {
|
|
2181
|
+
const resolve2 = async (ref) => {
|
|
2182
|
+
if (!ref.trim()) throw new ConfigError("Review refs must not be empty.");
|
|
2183
|
+
try {
|
|
2184
|
+
return (await readGit(cwd, ["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`], signal, 1024)).toString().trim();
|
|
2185
|
+
} catch (error) {
|
|
2186
|
+
assertNotAborted(signal);
|
|
2187
|
+
throw new ConfigError(`Cannot resolve review ref '${ref}'. Use valid commits and fetch complete history (actions/checkout fetch-depth: 0). ${error instanceof Error ? error.message : String(error)}`);
|
|
2188
|
+
}
|
|
2189
|
+
};
|
|
2190
|
+
try {
|
|
2191
|
+
const promises = (await readGit(cwd, ["config", "--type=bool", "--get-regexp", "^remote\\..*\\.promisor$"], signal, 64 * 1024)).toString();
|
|
2192
|
+
if (promises.split("\n").some((line) => /\s+(?:true|yes|on|1)\s*$/i.test(line))) {
|
|
2193
|
+
throw new ConfigError("Offline review requires a non-partial clone with local objects; promisor remotes are unsupported.");
|
|
2194
|
+
}
|
|
2195
|
+
} catch (error) {
|
|
2196
|
+
if (!(error instanceof GitError && error.code === 1)) throw error;
|
|
2197
|
+
}
|
|
2198
|
+
const baseSha = await resolve2(base);
|
|
2199
|
+
const headSha = await resolve2(head);
|
|
2200
|
+
let ancestors;
|
|
2201
|
+
try {
|
|
2202
|
+
ancestors = (await readGit(cwd, ["merge-base", "--all", "--end-of-options", baseSha, headSha], signal, 8192)).toString().trim().split("\n");
|
|
2203
|
+
} catch (error) {
|
|
2204
|
+
assertNotAborted(signal);
|
|
2205
|
+
throw new ConfigError(`Cannot find review merge-base. Fetch full shared history (fetch-depth: 0). ${error instanceof Error ? error.message : String(error)}`);
|
|
2206
|
+
}
|
|
2207
|
+
if (ancestors.length !== 1 || !/^[a-f0-9]{40,64}$/.test(ancestors[0])) throw new ConfigError("Review needs exactly one merge base; ambiguous merge ancestry is unsupported.");
|
|
2208
|
+
return { baseSha, headSha, mergeBaseSha: ancestors[0] };
|
|
2209
|
+
}
|
|
2210
|
+
async function changedTreeFiles(cwd, before, after, signal) {
|
|
2211
|
+
const buffer = await readGit(cwd, ["diff", "--raw", "-z", "--no-relative", "--no-abbrev", "--no-ext-diff", "--no-textconv", "--find-renames=50%", "-l1000", "--end-of-options", before, after, "--"], signal);
|
|
2212
|
+
const tokens = new TextDecoder("utf-8", { fatal: true }).decode(buffer).split("\0");
|
|
2213
|
+
if (tokens.pop() !== "") throw new ConfigError("Truncated Git change inventory.");
|
|
2214
|
+
const changes = [];
|
|
2215
|
+
for (let i = 0; i < tokens.length; ) {
|
|
2216
|
+
const match = /^:(\d{6}) (\d{6}) ([a-f0-9]{40,64}) ([a-f0-9]{40,64}) ([AMDRT]\d*)$/.exec(tokens[i++]);
|
|
2217
|
+
if (!match) throw new ConfigError("Unsupported Git change inventory entry.");
|
|
2218
|
+
const oldPath = tokens[i++];
|
|
2219
|
+
const newPath = match[5].startsWith("R") ? tokens[i++] : oldPath;
|
|
2220
|
+
if (!oldPath || !newPath) throw new ConfigError("Incomplete Git path inventory.");
|
|
2221
|
+
changes.push({ oldMode: match[1], newMode: match[2], oldOid: match[3], newOid: match[4], status: match[5], oldPath, newPath });
|
|
2222
|
+
if (changes.length > 1e3) throw new ConfigError("Review exceeds the 1000 changed-file limit; split the change into smaller reviews.");
|
|
2223
|
+
}
|
|
2224
|
+
return changes;
|
|
2225
|
+
}
|
|
2226
|
+
async function readBlob(cwd, oid, signal, limit = 2 * 1024 * 1024) {
|
|
2227
|
+
if (!/^[a-f0-9]{40,64}$/.test(oid)) throw new ConfigError("Invalid Git blob identity.");
|
|
2228
|
+
const sizeText = (await readGit(cwd, ["cat-file", "-s", oid], signal, 128)).toString().trim();
|
|
2229
|
+
const size = Number(sizeText);
|
|
2230
|
+
if (!Number.isSafeInteger(size) || size < 0 || size > limit) throw new ConfigError(`Source blob exceeds the ${limit} byte limit or has an invalid size.`);
|
|
2231
|
+
const buffer = await readGit(cwd, ["cat-file", "blob", oid], signal, limit);
|
|
2232
|
+
if (buffer.length !== size) throw new ConfigError("Git blob size differs from the recorded object.");
|
|
2233
|
+
return buffer;
|
|
2234
|
+
}
|
|
2235
|
+
async function reviewPolicy(cwd, baseSha, signal) {
|
|
2236
|
+
const raw = await readGit(cwd, ["ls-tree", "--full-tree", "-z", baseSha, "--", ...CONFIG_FILENAMES], signal, 8192);
|
|
2237
|
+
const entries = raw.toString().split("\0").filter(Boolean);
|
|
2238
|
+
for (const filename of CONFIG_FILENAMES) {
|
|
2239
|
+
const entry = entries.find((line) => line.slice(line.indexOf(" ") + 1) === filename);
|
|
2240
|
+
if (!entry) continue;
|
|
2241
|
+
const match = /^(100644|100755) blob ([a-f0-9]{40,64})\t/.exec(entry);
|
|
2242
|
+
if (!match) throw new ConfigError(`Base policy '${filename}' must be a regular Git blob.`);
|
|
2243
|
+
const content = await readBlob(cwd, match[2], signal, 64 * 1024);
|
|
2244
|
+
return { config: configFromText(new TextDecoder("utf-8", { fatal: true }).decode(content), `${filename} in base ${baseSha.slice(0, 7)}`), filename };
|
|
2245
|
+
}
|
|
2246
|
+
return { config: configFromText("{}", "default review policy"), filename: null };
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
// src/review.ts
|
|
2250
|
+
var GENERATED_PATHS2 = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/vendor/**", "**/*.min.js", "**/*.map"];
|
|
2251
|
+
var MAX_SCAN_BYTES2 = 50 * 1024 * 1024;
|
|
2252
|
+
function compareFindings(before, after, newPath) {
|
|
2253
|
+
const old = /* @__PURE__ */ new Map();
|
|
2254
|
+
for (const finding of before) {
|
|
2255
|
+
const key = findingFingerprint({ ...finding, file: newPath });
|
|
2256
|
+
const bucket = old.get(key) || { findings: [], matched: 0 };
|
|
2257
|
+
bucket.findings.push(finding);
|
|
2258
|
+
old.set(key, bucket);
|
|
2259
|
+
}
|
|
2260
|
+
const introduced = [];
|
|
2261
|
+
let unchanged = 0;
|
|
2262
|
+
for (const finding of after) {
|
|
2263
|
+
const bucket = old.get(findingFingerprint(finding));
|
|
2264
|
+
if (bucket && bucket.matched < bucket.findings.length) {
|
|
2265
|
+
bucket.matched++;
|
|
2266
|
+
unchanged++;
|
|
2267
|
+
} else introduced.push(finding);
|
|
2268
|
+
}
|
|
2269
|
+
return { introduced, removed: [...old.values()].flatMap((bucket) => bucket.findings.slice(bucket.matched)), unchanged };
|
|
2270
|
+
}
|
|
2271
|
+
async function reviewChanges(options) {
|
|
2272
|
+
assertNotAborted(options.signal);
|
|
2273
|
+
const cwd = path5.resolve(options.cwd || process.cwd());
|
|
2274
|
+
const refs = await reviewRefs(cwd, options.base, options.head ?? "HEAD", options.signal);
|
|
2275
|
+
const policy = await reviewPolicy(cwd, refs.baseSha, options.signal);
|
|
2276
|
+
const format = options.format ?? "terminal";
|
|
2277
|
+
if (!["terminal", "json", "github", "markdown", "sarif"].includes(format)) throw new ConfigError("Unsupported review format.");
|
|
2278
|
+
const failOn = options.failOn ?? policy.config.failOn ?? ["test-skip", "trivial-assert"];
|
|
2279
|
+
for (const rule of failOn) if (!VALID_DETECTORS.includes(rule)) throw new ConfigError(`Unknown review rule '${rule}'.`);
|
|
2280
|
+
const changes = await changedTreeFiles(cwd, refs.mergeBaseSha, refs.headSha, options.signal);
|
|
2281
|
+
const policyChanges = changes.filter((change) => CONFIG_FILENAMES.includes(change.oldPath) || CONFIG_FILENAMES.includes(change.newPath)).map((change) => change.newPath);
|
|
2282
|
+
const ignores = [...GENERATED_PATHS2, ...policy.config.ignorePaths || []];
|
|
2283
|
+
const result = {
|
|
2284
|
+
schemaVersion: "1.0.0",
|
|
2285
|
+
mode: "review",
|
|
2286
|
+
...refs,
|
|
2287
|
+
policySha: refs.baseSha,
|
|
2288
|
+
policyFile: policy.filename,
|
|
2289
|
+
policyChanges,
|
|
2290
|
+
filesChanged: changes.length,
|
|
2291
|
+
filesInspected: 0,
|
|
2292
|
+
unsupportedFiles: 0,
|
|
2293
|
+
excludedFiles: 0,
|
|
2294
|
+
renamedFiles: changes.filter((change) => change.status.startsWith("R")).length,
|
|
2295
|
+
linesInspected: 0,
|
|
2296
|
+
beforeFindings: 0,
|
|
2297
|
+
afterFindings: 0,
|
|
2298
|
+
unchangedCount: 0,
|
|
2299
|
+
infractions: [],
|
|
2300
|
+
removedFindings: [],
|
|
2301
|
+
skippedFiles: [],
|
|
2302
|
+
complete: true,
|
|
2303
|
+
failedRules: [],
|
|
2304
|
+
passed: false,
|
|
2305
|
+
exitCode: 2,
|
|
2306
|
+
output: ""
|
|
2307
|
+
};
|
|
2308
|
+
let totalBytes = 0;
|
|
2309
|
+
for (const change of changes) {
|
|
2310
|
+
assertNotAborted(options.signal);
|
|
2311
|
+
const oldSupported = change.oldMode !== "000000" && isCodeFile(change.oldPath);
|
|
2312
|
+
const newSupported = change.newMode !== "000000" && isCodeFile(change.newPath);
|
|
2313
|
+
if (!oldSupported && !newSupported) {
|
|
2314
|
+
result.unsupportedFiles++;
|
|
2315
|
+
continue;
|
|
2316
|
+
}
|
|
2317
|
+
const oldInScope = oldSupported && isCodeFile(change.oldPath, ignores);
|
|
2318
|
+
const newInScope = newSupported && isCodeFile(change.newPath, ignores);
|
|
2319
|
+
if (!oldInScope && !newInScope) {
|
|
2320
|
+
result.excludedFiles++;
|
|
2321
|
+
continue;
|
|
2322
|
+
}
|
|
2323
|
+
try {
|
|
2324
|
+
let inspectedLines = 0;
|
|
2325
|
+
const scan = async (mode, oid, filename, commit) => {
|
|
2326
|
+
if (mode === "000000") return [];
|
|
2327
|
+
if (!["100644", "100755"].includes(mode)) throw new ConfigError("Changed source is a symlink, submodule or unsupported Git mode; comparison is incomplete.");
|
|
2328
|
+
const content = await readBlob(cwd, oid, options.signal);
|
|
2329
|
+
totalBytes += content.length;
|
|
2330
|
+
if (totalBytes > MAX_SCAN_BYTES2) throw new ConfigError("Review exceeds the 50 MiB source comparison limit.");
|
|
2331
|
+
if (content.includes(0)) throw new ConfigError("Binary content in a source comparison.");
|
|
2332
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(content);
|
|
2333
|
+
const lines = text.split(/\r?\n/);
|
|
2334
|
+
if (lines.at(-1) === "") lines.pop();
|
|
2335
|
+
inspectedLines += lines.length;
|
|
2336
|
+
return auditFileDiffs([{ path: filename, addedLines: lines.map((line, index) => ({ line, lineNumber: index + 1 })) }], commit, "", { rules: policy.config.rules }).infractions;
|
|
2337
|
+
};
|
|
2338
|
+
if (oldSupported && !newSupported && change.newMode !== "000000") {
|
|
2339
|
+
throw new ConfigError("Source moved to an unsupported extension; cannot claim its findings were resolved.");
|
|
2340
|
+
}
|
|
2341
|
+
const before = await scan(change.oldMode, change.oldOid, change.oldPath, refs.mergeBaseSha);
|
|
2342
|
+
const after = await scan(change.newMode, change.newOid, change.newPath, refs.headSha);
|
|
2343
|
+
const compared = compareFindings(before, after, change.newPath);
|
|
2344
|
+
result.infractions.push(...compared.introduced);
|
|
2345
|
+
result.removedFindings.push(...compared.removed);
|
|
2346
|
+
result.unchangedCount += compared.unchanged;
|
|
2347
|
+
result.beforeFindings += before.length;
|
|
2348
|
+
result.afterFindings += after.length;
|
|
2349
|
+
result.filesInspected++;
|
|
2350
|
+
result.linesInspected += inspectedLines;
|
|
2351
|
+
} catch (error) {
|
|
2352
|
+
assertNotAborted(options.signal);
|
|
2353
|
+
result.skippedFiles.push({ file: change.newPath, reason: error instanceof Error ? error.message : String(error) });
|
|
2354
|
+
if (totalBytes > MAX_SCAN_BYTES2) break;
|
|
2355
|
+
}
|
|
2356
|
+
}
|
|
2357
|
+
assertNotAborted(options.signal);
|
|
2358
|
+
result.complete = result.skippedFiles.length === 0;
|
|
2359
|
+
result.failedRules = [...new Set(result.infractions.filter((finding) => failOn.includes(finding.type)).map((finding) => finding.type))];
|
|
2360
|
+
result.passed = result.complete && result.failedRules.length === 0;
|
|
2361
|
+
result.exitCode = !result.complete ? 2 : result.passed ? 0 : 1;
|
|
2362
|
+
if (format === "json") result.output = JSON.stringify(result, null, 2);
|
|
2363
|
+
else if (format === "markdown") result.output = formatReviewMarkdown(result);
|
|
2364
|
+
else if (format === "sarif") result.output = formatSarif(result);
|
|
2365
|
+
else if (format === "github") result.output = formatGithubAnnotations(result) + (!result.complete ? "\n::error::Context review incomplete. Inspect skipped source files in the JSON report." : "") + (policyChanges.length ? "\n::notice::Candidate policy changes were ignored; review uses policy from the supplied base commit." : "");
|
|
2366
|
+
else result.output = formatReviewTerminal(result);
|
|
2367
|
+
return result;
|
|
2368
|
+
}
|
|
2369
|
+
|
|
1616
2370
|
// src/index.ts
|
|
1617
2371
|
var InvalidDetectorError = class extends Error {
|
|
1618
2372
|
constructor(message) {
|
|
@@ -1621,10 +2375,10 @@ var InvalidDetectorError = class extends Error {
|
|
|
1621
2375
|
}
|
|
1622
2376
|
};
|
|
1623
2377
|
async function roastRepository(options = {}) {
|
|
1624
|
-
const cwd =
|
|
2378
|
+
const cwd = path6.resolve(options.cwd || process.cwd());
|
|
1625
2379
|
const since = options.since || "90 days ago";
|
|
1626
|
-
const repoName =
|
|
1627
|
-
const collector = new MetricsCollector({ all: options.all, ignorePaths: options.ignorePaths });
|
|
2380
|
+
const repoName = path6.basename(cwd);
|
|
2381
|
+
const collector = new MetricsCollector({ all: options.all, ignorePaths: options.ignorePaths, rules: options.rules, panicLoopWindowMinutes: options.panicLoopWindowMinutes });
|
|
1628
2382
|
const gitStream = streamGitLog(cwd, since);
|
|
1629
2383
|
await parseGitLogStream(gitStream, (commit, stats) => {
|
|
1630
2384
|
collector.processCommit(commit);
|
|
@@ -1638,7 +2392,7 @@ async function roastRepository(options = {}) {
|
|
|
1638
2392
|
return { summary, output };
|
|
1639
2393
|
}
|
|
1640
2394
|
async function auditPullRequest(options) {
|
|
1641
|
-
const cwd =
|
|
2395
|
+
const cwd = path6.resolve(options.cwd || process.cwd());
|
|
1642
2396
|
const format = options.format || "terminal";
|
|
1643
2397
|
const failOn = (options.failOn || ["test-skip"]).map((s) => s.trim()).filter(Boolean);
|
|
1644
2398
|
for (const det of failOn) {
|
|
@@ -1648,10 +2402,16 @@ async function auditPullRequest(options) {
|
|
|
1648
2402
|
);
|
|
1649
2403
|
}
|
|
1650
2404
|
}
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
2405
|
+
if (options.staged && options.base) {
|
|
2406
|
+
throw new GitResolutionError("--staged cannot be combined with --base.");
|
|
2407
|
+
}
|
|
2408
|
+
if (!options.staged && !options.base) {
|
|
2409
|
+
throw new GitResolutionError("A base ref is required for a pull request audit.");
|
|
2410
|
+
}
|
|
2411
|
+
const baseSha = options.staged ? "INDEX" : resolveCommitSha(cwd, options.base, "Base ref");
|
|
2412
|
+
const headSha = options.staged ? "INDEX" : resolveCommitSha(cwd, options.head || "HEAD", "Head ref");
|
|
2413
|
+
const mergeBaseSha = options.staged ? "INDEX" : findMergeBase(cwd, baseSha, headSha);
|
|
2414
|
+
const diffStream = streamGitDiff(cwd, mergeBaseSha, headSha, options.staged);
|
|
1655
2415
|
let rawDiff = "";
|
|
1656
2416
|
await new Promise((resolve2, reject) => {
|
|
1657
2417
|
diffStream.on("data", (chunk) => {
|
|
@@ -1661,7 +2421,7 @@ async function auditPullRequest(options) {
|
|
|
1661
2421
|
diffStream.on("error", reject);
|
|
1662
2422
|
});
|
|
1663
2423
|
const { files, skippedLongLines, skippedUnknownFiles } = parseDiff(rawDiff);
|
|
1664
|
-
const audit = auditFileDiffs(files, headSha, (/* @__PURE__ */ new Date()).toISOString(), { ignorePaths: options.ignorePaths });
|
|
2424
|
+
const audit = auditFileDiffs(files, headSha, (/* @__PURE__ */ new Date()).toISOString(), { ignorePaths: options.ignorePaths, rules: options.rules });
|
|
1665
2425
|
const infractions = audit.infractions;
|
|
1666
2426
|
const defectDensityPer100Lines = audit.linesAdded > 0 ? Math.round(infractions.length / audit.linesAdded * 100 * 100) / 100 : 0;
|
|
1667
2427
|
const failedRules = [...new Set(
|
|
@@ -1671,6 +2431,7 @@ async function auditPullRequest(options) {
|
|
|
1671
2431
|
const exitCode = passed ? 0 : 1;
|
|
1672
2432
|
const result = {
|
|
1673
2433
|
schemaVersion: "1.0.0",
|
|
2434
|
+
mode: options.staged ? "staged" : "pr",
|
|
1674
2435
|
baseSha,
|
|
1675
2436
|
headSha,
|
|
1676
2437
|
mergeBaseSha,
|
|
@@ -1713,7 +2474,12 @@ export {
|
|
|
1713
2474
|
resolveCommitSha,
|
|
1714
2475
|
findMergeBase,
|
|
1715
2476
|
GitResolutionError,
|
|
2477
|
+
RULE_GUIDES,
|
|
2478
|
+
VALID_DETECTORS,
|
|
1716
2479
|
DEFAULT_CONFIG,
|
|
2480
|
+
CONFIG_FILENAMES,
|
|
2481
|
+
ConfigError,
|
|
2482
|
+
configFromText,
|
|
1717
2483
|
loadConfig,
|
|
1718
2484
|
matchIgnorePath,
|
|
1719
2485
|
isCodeFile,
|
|
@@ -1727,7 +2493,10 @@ export {
|
|
|
1727
2493
|
formatGithubAnnotations,
|
|
1728
2494
|
formatGithubStepSummary,
|
|
1729
2495
|
formatPrMarkdownComment,
|
|
1730
|
-
|
|
2496
|
+
formatCheckTerminal,
|
|
2497
|
+
formatCheckMarkdown,
|
|
2498
|
+
formatReviewTerminal,
|
|
2499
|
+
formatReviewMarkdown,
|
|
1731
2500
|
DEFAULT_CONFIG_TEMPLATE,
|
|
1732
2501
|
GITHUB_WORKFLOW_TEMPLATE,
|
|
1733
2502
|
PRE_COMMIT_HOOK_TEMPLATE,
|
|
@@ -1736,6 +2505,15 @@ export {
|
|
|
1736
2505
|
generateSvgBadge,
|
|
1737
2506
|
generateShieldsMarkdown,
|
|
1738
2507
|
generateShieldsJson,
|
|
2508
|
+
findingFingerprint,
|
|
2509
|
+
createBaseline,
|
|
2510
|
+
loadBaseline,
|
|
2511
|
+
applyBaseline,
|
|
2512
|
+
writeBaseline,
|
|
2513
|
+
formatSarif,
|
|
2514
|
+
checkProject,
|
|
2515
|
+
compareFindings,
|
|
2516
|
+
reviewChanges,
|
|
1739
2517
|
InvalidDetectorError,
|
|
1740
2518
|
roastRepository,
|
|
1741
2519
|
auditPullRequest
|