agent-roast 0.4.0 → 0.5.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 +119 -21
- package/dist/{chunk-O4SB2FCT.js → chunk-FUAUIURY.js} +627 -224
- package/dist/cli.js +81 -16
- package/dist/index.d.ts +85 -22
- package/dist/index.js +23 -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 path5 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,67 @@ 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
|
+
}
|
|
574
757
|
function loadConfig(cwd = process.cwd()) {
|
|
575
758
|
for (const filename of CONFIG_FILENAMES) {
|
|
576
759
|
const filePath = path.join(cwd, filename);
|
|
577
|
-
if (fs.existsSync(filePath))
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
}
|
|
593
|
-
} catch (err) {
|
|
594
|
-
console.warn(`Warning: Failed to parse ${filename}: ${err.message}. Using default configuration.`);
|
|
595
|
-
}
|
|
760
|
+
if (!fs.existsSync(filePath)) continue;
|
|
761
|
+
try {
|
|
762
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
763
|
+
validateConfig(parsed);
|
|
764
|
+
const rules = { ...DEFAULT_CONFIG.rules, ...parsed.rules };
|
|
765
|
+
const failOn = parsed.failOn ?? Object.entries(rules).filter(([, level]) => level === "error").map(([rule]) => rule);
|
|
766
|
+
return {
|
|
767
|
+
...DEFAULT_CONFIG,
|
|
768
|
+
...parsed,
|
|
769
|
+
failOn,
|
|
770
|
+
ignorePaths: [.../* @__PURE__ */ new Set([...DEFAULT_CONFIG.ignorePaths || [], ...parsed.ignorePaths || []])],
|
|
771
|
+
rules
|
|
772
|
+
};
|
|
773
|
+
} catch (err) {
|
|
774
|
+
throw new ConfigError(`Invalid ${filename}: ${err instanceof Error ? err.message : String(err)}`);
|
|
596
775
|
}
|
|
597
776
|
}
|
|
598
|
-
return {
|
|
777
|
+
return {
|
|
778
|
+
...DEFAULT_CONFIG,
|
|
779
|
+
failOn: [...DEFAULT_CONFIG.failOn || []],
|
|
780
|
+
ignorePaths: [...DEFAULT_CONFIG.ignorePaths || []],
|
|
781
|
+
rules: { ...DEFAULT_CONFIG.rules }
|
|
782
|
+
};
|
|
599
783
|
}
|
|
600
784
|
function matchIgnorePath(filePath, ignorePatterns) {
|
|
601
785
|
if (!ignorePatterns || ignorePatterns.length === 0) {
|
|
@@ -605,7 +789,25 @@ function matchIgnorePath(filePath, ignorePatterns) {
|
|
|
605
789
|
for (const pattern of ignorePatterns) {
|
|
606
790
|
if (!pattern) continue;
|
|
607
791
|
const cleanPattern = pattern.replace(/\\/g, "/");
|
|
608
|
-
let regexPattern =
|
|
792
|
+
let regexPattern = "";
|
|
793
|
+
for (let i = 0; i < cleanPattern.length; i++) {
|
|
794
|
+
const char = cleanPattern[i];
|
|
795
|
+
if (char === "*" && cleanPattern[i + 1] === "*") {
|
|
796
|
+
i++;
|
|
797
|
+
if (cleanPattern[i + 1] === "/") {
|
|
798
|
+
regexPattern += "(?:.*/)?";
|
|
799
|
+
i++;
|
|
800
|
+
} else {
|
|
801
|
+
regexPattern += ".*";
|
|
802
|
+
}
|
|
803
|
+
} else if (char === "*") {
|
|
804
|
+
regexPattern += "[^/]*";
|
|
805
|
+
} else if (char === "?") {
|
|
806
|
+
regexPattern += "[^/]";
|
|
807
|
+
} else {
|
|
808
|
+
regexPattern += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
809
|
+
}
|
|
810
|
+
}
|
|
609
811
|
if (!regexPattern.startsWith("^") && !regexPattern.startsWith("(?:^")) {
|
|
610
812
|
regexPattern = "(?:^|/)" + regexPattern;
|
|
611
813
|
}
|
|
@@ -628,7 +830,7 @@ function matchIgnorePath(filePath, ignorePatterns) {
|
|
|
628
830
|
|
|
629
831
|
// src/metrics.ts
|
|
630
832
|
var TEST_SKIP_PATTERNS = [
|
|
631
|
-
{ regex:
|
|
833
|
+
{ regex: /\b(?:it|test|describe)\.skip\b/, name: "skipped test (it.skip / describe.skip)" },
|
|
632
834
|
{ regex: /\b(?:xit|xtest)\(/, name: "disabled test (xit / xtest)" },
|
|
633
835
|
{ regex: /@pytest\.mark\.skip\b/, name: "skipped pytest (@pytest.mark.skip)" },
|
|
634
836
|
{ regex: /\bpytest\.skip\(/, name: "imperative pytest skip (pytest.skip)" },
|
|
@@ -652,9 +854,9 @@ var SWALLOWED_ERROR_PATTERNS = [
|
|
|
652
854
|
{ regex: /except(?:\s+[^:]*)?:\s*pass\b/, name: "silent python error swallow (except: pass)" }
|
|
653
855
|
];
|
|
654
856
|
var TRIVIAL_ASSERT_PATTERNS = [
|
|
655
|
-
{ regex: /expect\s*\(\s*(
|
|
656
|
-
{ regex: /expect\s*\(\s*(
|
|
657
|
-
{ regex: /\bassert\s+(?:True|
|
|
857
|
+
{ regex: /expect\s*\(\s*(true|false|1|0|null|undefined)\s*\)\.toBe\s*\(\s*\1\s*\)/, name: "trivial assertion (expect(val).toBe(val))" },
|
|
858
|
+
{ regex: /expect\s*\(\s*(true|false|1|0|null|undefined)\s*\)\.toEqual\s*\(\s*\1\s*\)/, name: "trivial assertion (expect(val).toEqual(val))" },
|
|
859
|
+
{ regex: /\bassert\s+(?:True|1\s*==\s*1)\s*(?=$|#|,)/, name: "trivial python assert (assert True / 1 == 1)" },
|
|
658
860
|
{ regex: /self\.assertTrue\s*\(\s*(?:True|1)\s*\)/, name: "trivial assert (self.assertTrue(True))" },
|
|
659
861
|
{ regex: /self\.assertEqual\s*\(\s*(?:1\s*,\s*1|True\s*,\s*True)\s*\)/, name: "trivial assert (self.assertEqual(1, 1))" },
|
|
660
862
|
{ regex: /assert\.(?:strictEqual|equal)\s*\(\s*(?:true\s*,\s*true|1\s*,\s*1)\s*\)/, name: "trivial assert (assert.equal(1, 1))" }
|
|
@@ -687,7 +889,7 @@ var CODE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
|
687
889
|
".c"
|
|
688
890
|
]);
|
|
689
891
|
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;
|
|
892
|
+
var TEST_PATH_REGEX = /(?:^|\/)test_[^/]+\.py$|(?:^|\/)(?:tests?|__tests__|specs?)\/|[._-](?:test|spec)\.[a-z0-9]+$/i;
|
|
691
893
|
function isCodeFile(filePath, customIgnorePaths) {
|
|
692
894
|
if (IGNORED_PATH_REGEX.test(filePath)) {
|
|
693
895
|
return false;
|
|
@@ -712,11 +914,14 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
712
914
|
hasSupportedChanges = true;
|
|
713
915
|
filesInspected++;
|
|
714
916
|
linesAdded += file.addedLines.length;
|
|
917
|
+
const masker = new SourceMasker(file.path);
|
|
918
|
+
const maskedLines = [];
|
|
715
919
|
for (const added of file.addedLines) {
|
|
716
920
|
const rawLine = added.line;
|
|
717
|
-
const
|
|
718
|
-
const
|
|
719
|
-
|
|
921
|
+
const masked = masker.mask(rawLine, added.lineNumber);
|
|
922
|
+
const cleanedLine = masked.code;
|
|
923
|
+
maskedLines.push({ code: cleanedLine, raw: rawLine, number: added.lineNumber });
|
|
924
|
+
const isComment = !cleanedLine.trim();
|
|
720
925
|
if (!isComment) {
|
|
721
926
|
for (const rule of TEST_SKIP_PATTERNS) {
|
|
722
927
|
if (rule.regex.test(cleanedLine)) {
|
|
@@ -735,7 +940,7 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
735
940
|
}
|
|
736
941
|
let foundEscape = false;
|
|
737
942
|
for (const rule of COMMENT_DIRECTIVE_PATTERNS) {
|
|
738
|
-
if (rule.regex.test(
|
|
943
|
+
if (rule.regex.test(masked.comments)) {
|
|
739
944
|
infractions.push({
|
|
740
945
|
type: "type-escape",
|
|
741
946
|
file: file.path,
|
|
@@ -799,7 +1004,8 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
799
1004
|
}
|
|
800
1005
|
if (!isComment) {
|
|
801
1006
|
for (const rule of STUB_ABANDONMENT_PATTERNS) {
|
|
802
|
-
|
|
1007
|
+
const match = rule.regex.exec(rawLine);
|
|
1008
|
+
if (match && cleanedLine[match.index] === rawLine[match.index]) {
|
|
803
1009
|
infractions.push({
|
|
804
1010
|
type: "stub-abandonment",
|
|
805
1011
|
file: file.path,
|
|
@@ -814,14 +1020,39 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
814
1020
|
}
|
|
815
1021
|
}
|
|
816
1022
|
}
|
|
1023
|
+
for (let index = 0; index < maskedLines.length; index++) {
|
|
1024
|
+
const start = maskedLines[index];
|
|
1025
|
+
const isCatch = /\bcatch\s*(?:\([^)]*\))?\s*\{\s*$/.test(start.code);
|
|
1026
|
+
const isExcept = /\bexcept(?:\s+[^:]*)?:\s*$/.test(start.code);
|
|
1027
|
+
if (!isCatch && !isExcept) continue;
|
|
1028
|
+
let next = index + 1;
|
|
1029
|
+
while (next < maskedLines.length && maskedLines[next].number === maskedLines[next - 1].number + 1 && !maskedLines[next].code.trim()) next++;
|
|
1030
|
+
const end = maskedLines[next];
|
|
1031
|
+
if (!end || end.number !== maskedLines[next - 1].number + 1) continue;
|
|
1032
|
+
const closesCatch = isCatch && /^\s*}/.test(end.code);
|
|
1033
|
+
let closesExcept = isExcept && /^\s*pass\s*$/.test(end.code);
|
|
1034
|
+
if (closesExcept) {
|
|
1035
|
+
const indent = start.raw.search(/\S/);
|
|
1036
|
+
if (end.raw.search(/\S/) <= indent) closesExcept = false;
|
|
1037
|
+
let after = next + 1;
|
|
1038
|
+
while (after < maskedLines.length && maskedLines[after].number === maskedLines[after - 1].number + 1 && !maskedLines[after].code.trim()) after++;
|
|
1039
|
+
if (after < maskedLines.length && maskedLines[after].number === maskedLines[after - 1].number + 1 && maskedLines[after].raw.search(/\S/) > indent) closesExcept = false;
|
|
1040
|
+
}
|
|
1041
|
+
if (closesCatch || closesExcept) infractions.push({
|
|
1042
|
+
type: "swallowed-error",
|
|
1043
|
+
file: file.path,
|
|
1044
|
+
lineNumber: start.number,
|
|
1045
|
+
lineSnippet: maskedLines.slice(index, next + 1).map((line) => line.raw.trim()).join(" "),
|
|
1046
|
+
commitHash,
|
|
1047
|
+
commitDate,
|
|
1048
|
+
reason: closesCatch ? "empty multi-line catch block" : "silent multi-line python error swallow"
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
817
1051
|
}
|
|
818
|
-
return { infractions, hasSupportedChanges, filesInspected, linesAdded };
|
|
1052
|
+
return { infractions: infractions.filter((inf) => options.rules?.[inf.type] !== "off").map((inf) => ({ ...inf, suggestion: RULE_GUIDES[inf.type].suggestion })), hasSupportedChanges, filesInspected, linesAdded };
|
|
819
1053
|
}
|
|
820
1054
|
function stripStringAndRegexLiterals(line) {
|
|
821
|
-
return
|
|
822
|
-
}
|
|
823
|
-
function isCommentOnlyLine(trimmedLine) {
|
|
824
|
-
return trimmedLine.startsWith("//") || trimmedLine.startsWith("/*") || trimmedLine.startsWith("*") || trimmedLine.startsWith("#");
|
|
1055
|
+
return new SourceMasker("source.ts").mask(line, 1).code;
|
|
825
1056
|
}
|
|
826
1057
|
var PANIC_COMMIT_REGEX = /\b(?:fix|quickfix|revert|hotfix|retry|patch|undo|workaround)\b/i;
|
|
827
1058
|
var MAX_INFRACTIONS_PER_COMMIT = 10;
|
|
@@ -842,16 +1073,20 @@ var MetricsCollector = class {
|
|
|
842
1073
|
activePanicChain = null;
|
|
843
1074
|
includeAll;
|
|
844
1075
|
ignorePaths;
|
|
1076
|
+
rules;
|
|
1077
|
+
panicLoopWindowMinutes;
|
|
845
1078
|
constructor(options = {}) {
|
|
846
1079
|
this.includeAll = options.all ?? false;
|
|
847
1080
|
this.ignorePaths = options.ignorePaths;
|
|
1081
|
+
this.rules = options.rules;
|
|
1082
|
+
this.panicLoopWindowMinutes = options.panicLoopWindowMinutes ?? 15;
|
|
848
1083
|
}
|
|
849
1084
|
recordSkipped(stats) {
|
|
850
1085
|
if (stats.skippedLongLines) this.skippedLongLines += stats.skippedLongLines;
|
|
851
1086
|
if (stats.skippedUnknownFiles) this.skippedUnknownFiles += stats.skippedUnknownFiles;
|
|
852
1087
|
}
|
|
853
1088
|
flushPanicChain() {
|
|
854
|
-
if (this.activePanicChain && this.activePanicChain.commitCount >= 2) {
|
|
1089
|
+
if (this.rules?.["panic-loop"] !== "off" && this.activePanicChain && this.activePanicChain.commitCount >= 2) {
|
|
855
1090
|
const chain = this.activePanicChain;
|
|
856
1091
|
const durationMinutes = Math.max(
|
|
857
1092
|
1,
|
|
@@ -876,6 +1111,7 @@ var MetricsCollector = class {
|
|
|
876
1111
|
this.aiLinesAdded += commit.totalAddedCount;
|
|
877
1112
|
}
|
|
878
1113
|
if (!isTarget) {
|
|
1114
|
+
this.flushPanicChain();
|
|
879
1115
|
return;
|
|
880
1116
|
}
|
|
881
1117
|
const subject = commit.message.split("\n")[0];
|
|
@@ -888,7 +1124,7 @@ var MetricsCollector = class {
|
|
|
888
1124
|
if (this.activePanicChain && this.activePanicChain.author.toLowerCase() === commit.authorEmail.toLowerCase()) {
|
|
889
1125
|
const diffMinutes = Math.abs(commitDateMs - this.activePanicChain.latestDate) / (1e3 * 60);
|
|
890
1126
|
const touchedSameFiles = currentActiveFiles.size > 0 && [...currentActiveFiles].some((p) => this.activePanicChain.activeFiles.has(p));
|
|
891
|
-
if (diffMinutes > 0 && diffMinutes <=
|
|
1127
|
+
if (diffMinutes > 0 && diffMinutes <= this.panicLoopWindowMinutes && touchedSameFiles) {
|
|
892
1128
|
this.activePanicChain.commitCount++;
|
|
893
1129
|
this.activePanicChain.latestHash = commit.hash;
|
|
894
1130
|
this.activePanicChain.latestDate = commitDateMs;
|
|
@@ -927,123 +1163,23 @@ var MetricsCollector = class {
|
|
|
927
1163
|
} else {
|
|
928
1164
|
this.flushPanicChain();
|
|
929
1165
|
}
|
|
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
|
-
}
|
|
1166
|
+
const audit = auditFileDiffs(commit.files, commit.hash, commit.date, {
|
|
1167
|
+
ignorePaths: this.ignorePaths,
|
|
1168
|
+
rules: this.rules
|
|
1169
|
+
});
|
|
1170
|
+
const buckets = {
|
|
1171
|
+
"test-skip": this.testSkips,
|
|
1172
|
+
"type-escape": this.typeEscapes,
|
|
1173
|
+
"swallowed-error": this.swallowedErrors,
|
|
1174
|
+
"trivial-assert": this.trivialAsserts,
|
|
1175
|
+
"stub-abandonment": this.stubAbandonments,
|
|
1176
|
+
"panic-loop": this.panicLoops
|
|
1177
|
+
};
|
|
1178
|
+
const counts = {};
|
|
1179
|
+
for (const infraction of audit.infractions) {
|
|
1180
|
+
const count = counts[infraction.type] || 0;
|
|
1181
|
+
if (count < MAX_INFRACTIONS_PER_COMMIT) buckets[infraction.type].push(infraction);
|
|
1182
|
+
counts[infraction.type] = count + 1;
|
|
1047
1183
|
}
|
|
1048
1184
|
}
|
|
1049
1185
|
getSummary() {
|
|
@@ -1161,25 +1297,26 @@ function formatTerminalCard(summary, options) {
|
|
|
1161
1297
|
);
|
|
1162
1298
|
lines.push("");
|
|
1163
1299
|
if (!summary.isSufficientData || summary.score === null) {
|
|
1164
|
-
lines.push(pc.yellow(` insufficient data (< 200 lines added
|
|
1165
|
-
lines.push(pc.dim("
|
|
1300
|
+
lines.push(pc.yellow(` insufficient data (< 200 analyzed lines added)`));
|
|
1301
|
+
lines.push(pc.dim(" Findings below remain actionable; a score needs a larger sample."));
|
|
1302
|
+
lines.push("");
|
|
1303
|
+
}
|
|
1304
|
+
if (summary.isSufficientData && summary.score !== null) {
|
|
1305
|
+
let scoreColor = pc.green;
|
|
1306
|
+
if (summary.score < 60) scoreColor = pc.red;
|
|
1307
|
+
else if (summary.score < 80) scoreColor = pc.yellow;
|
|
1308
|
+
lines.push(
|
|
1309
|
+
` ${pc.bold("SCORE:")} ${scoreColor(pc.bold(`${summary.score} / 100`))} ${pc.dim("\xB7")} ${pc.magenta(pc.bold(summary.archetype))}`
|
|
1310
|
+
);
|
|
1311
|
+
lines.push(` ${pc.dim(summary.archetypeDescription)}`);
|
|
1166
1312
|
lines.push("");
|
|
1167
|
-
return lines.join("\n");
|
|
1168
1313
|
}
|
|
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
1314
|
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
1315
|
lines.push(pc.bold(` INFRACTIONS (${totalInfractions})`));
|
|
1179
1316
|
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
1317
|
if (summary.panicLoops && summary.panicLoops.length > 0) {
|
|
1181
1318
|
lines.push(
|
|
1182
|
-
` ${pc.magenta("\u25CF")} ${pc.bold("panic-loop")} (${summary.panicLoops.length}) ${pc.dim("consecutive
|
|
1319
|
+
` ${pc.magenta("\u25CF")} ${pc.bold("panic-loop")} (${summary.panicLoops.length}) ${pc.dim("rapid consecutive fixes touching overlapping files")}`
|
|
1183
1320
|
);
|
|
1184
1321
|
const sample = summary.panicLoops[0];
|
|
1185
1322
|
lines.push(` ${pc.dim(sample.reason)}`);
|
|
@@ -1278,10 +1415,10 @@ function formatPrTerminalCard(result) {
|
|
|
1278
1415
|
const lines = [];
|
|
1279
1416
|
lines.push("");
|
|
1280
1417
|
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)}`)}`
|
|
1418
|
+
`${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
1419
|
);
|
|
1283
1420
|
if (!result.hasSupportedChanges) {
|
|
1284
|
-
lines.push(` ${pc.dim("no supported source changes detected in
|
|
1421
|
+
lines.push(` ${pc.dim("no supported source changes detected in the audited diff.")}`);
|
|
1285
1422
|
lines.push("");
|
|
1286
1423
|
return lines.join("\n");
|
|
1287
1424
|
}
|
|
@@ -1290,7 +1427,7 @@ function formatPrTerminalCard(result) {
|
|
|
1290
1427
|
);
|
|
1291
1428
|
lines.push("");
|
|
1292
1429
|
if (result.infractions.length === 0) {
|
|
1293
|
-
lines.push(` ${pc.green("PASS")} ${pc.dim("zero infractions detected across all
|
|
1430
|
+
lines.push(` ${pc.green("PASS")} ${pc.dim("zero infractions detected across all audited changes.")}`);
|
|
1294
1431
|
} else {
|
|
1295
1432
|
lines.push(pc.bold(` INFRACTIONS DETECTED (${result.infractions.length})`));
|
|
1296
1433
|
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 +1439,7 @@ function formatPrTerminalCard(result) {
|
|
|
1302
1439
|
if (inf.lineSnippet) {
|
|
1303
1440
|
lines.push(` ${pc.dim(`\u2514\u2500 ${inf.lineSnippet}`)}`);
|
|
1304
1441
|
}
|
|
1442
|
+
if (inf.suggestion) lines.push(` ${pc.dim(`Next: ${inf.suggestion}`)}`);
|
|
1305
1443
|
}
|
|
1306
1444
|
}
|
|
1307
1445
|
lines.push("");
|
|
@@ -1328,6 +1466,10 @@ function sortInfractionsDeterministic(infractions) {
|
|
|
1328
1466
|
return (a.lineNumber || 0) - (b.lineNumber || 0);
|
|
1329
1467
|
});
|
|
1330
1468
|
}
|
|
1469
|
+
function escapeCommand(value, property = false) {
|
|
1470
|
+
const escaped = value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
|
|
1471
|
+
return property ? escaped.replace(/:/g, "%3A").replace(/,/g, "%2C") : escaped;
|
|
1472
|
+
}
|
|
1331
1473
|
function formatGithubAnnotations(result) {
|
|
1332
1474
|
const outputLines = [];
|
|
1333
1475
|
const sorted = sortInfractionsDeterministic(result.infractions);
|
|
@@ -1337,20 +1479,20 @@ function formatGithubAnnotations(result) {
|
|
|
1337
1479
|
let omittedCount = 0;
|
|
1338
1480
|
for (const inf of sorted) {
|
|
1339
1481
|
if (!inf.file || inf.file === "unknown") continue;
|
|
1340
|
-
const isError = result.failedRules.includes(inf.type)
|
|
1482
|
+
const isError = result.failedRules.includes(inf.type);
|
|
1341
1483
|
const line = inf.lineNumber || 1;
|
|
1342
|
-
const title = inf.reason
|
|
1343
|
-
const msg = (inf.lineSnippet || inf.reason)
|
|
1484
|
+
const title = escapeCommand(inf.reason, true);
|
|
1485
|
+
const msg = escapeCommand(inf.lineSnippet || inf.reason);
|
|
1344
1486
|
if (isError) {
|
|
1345
1487
|
if (errorCount < MAX_PER_LEVEL) {
|
|
1346
|
-
outputLines.push(`::error file=${inf.file},line=${line},title=${title}::${msg}`);
|
|
1488
|
+
outputLines.push(`::error file=${escapeCommand(inf.file, true)},line=${line},title=${title}::${msg}`);
|
|
1347
1489
|
errorCount++;
|
|
1348
1490
|
} else {
|
|
1349
1491
|
omittedCount++;
|
|
1350
1492
|
}
|
|
1351
1493
|
} else {
|
|
1352
1494
|
if (warnCount < MAX_PER_LEVEL) {
|
|
1353
|
-
outputLines.push(`::warning file=${inf.file},line=${line},title=${title}::${msg}`);
|
|
1495
|
+
outputLines.push(`::warning file=${escapeCommand(inf.file, true)},line=${line},title=${title}::${msg}`);
|
|
1354
1496
|
warnCount++;
|
|
1355
1497
|
} else {
|
|
1356
1498
|
omittedCount++;
|
|
@@ -1366,10 +1508,10 @@ function formatGithubAnnotations(result) {
|
|
|
1366
1508
|
}
|
|
1367
1509
|
function formatGithubStepSummary(result) {
|
|
1368
1510
|
const lines = [];
|
|
1369
|
-
lines.push("### agent-roast Pull Request Audit");
|
|
1511
|
+
lines.push(result.mode === "staged" ? "### agent-roast Staged Changes Audit" : "### agent-roast Pull Request Audit");
|
|
1370
1512
|
lines.push("");
|
|
1371
1513
|
if (!result.hasSupportedChanges) {
|
|
1372
|
-
lines.push("No supported source file changes detected in
|
|
1514
|
+
lines.push("No supported source file changes detected in the audited diff.");
|
|
1373
1515
|
return lines.join("\n");
|
|
1374
1516
|
}
|
|
1375
1517
|
const statusBadge = result.passed ? "Passed" : `Failed (${result.failedRules.join(", ")})`;
|
|
@@ -1404,7 +1546,7 @@ function formatPrMarkdownComment(result) {
|
|
|
1404
1546
|
lines.push(`## \u{1F6E1}\uFE0F agent-roast audit: ${titleStatus} ${titleEmoji}`);
|
|
1405
1547
|
lines.push("");
|
|
1406
1548
|
if (!result.hasSupportedChanges) {
|
|
1407
|
-
lines.push("No supported source file changes detected in
|
|
1549
|
+
lines.push("No supported source file changes detected in the audited diff.");
|
|
1408
1550
|
lines.push("");
|
|
1409
1551
|
lines.push("---");
|
|
1410
1552
|
lines.push("*Audited 100% offline with [agent-roast](https://github.com/Nari-Ab/agent-roast)*");
|
|
@@ -1440,22 +1582,52 @@ function formatPrMarkdownComment(result) {
|
|
|
1440
1582
|
lines.push("*Audited 100% offline with [agent-roast](https://github.com/Nari-Ab/agent-roast)*");
|
|
1441
1583
|
return lines.join("\n");
|
|
1442
1584
|
}
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
];
|
|
1585
|
+
function terminalText(value) {
|
|
1586
|
+
return value.replace(/[\u0000-\u001f\u007f-\u009f]/g, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
1587
|
+
}
|
|
1588
|
+
function formatCheckTerminal(result) {
|
|
1589
|
+
const lines = [
|
|
1590
|
+
"",
|
|
1591
|
+
pc.bold("agent-roast \u2014 current code check"),
|
|
1592
|
+
` ${result.filesInspected} files \xB7 ${result.linesInspected} lines inspected`,
|
|
1593
|
+
` ${result.infractions.length} findings to review \xB7 ${result.baselineCount} accepted baseline findings`
|
|
1594
|
+
];
|
|
1595
|
+
if (result.staleBaselineCount) lines.push(` ${result.staleBaselineCount} baseline findings no longer present (or now disabled/ignored).`);
|
|
1596
|
+
for (const inf of result.infractions.slice(0, 20)) {
|
|
1597
|
+
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)}`);
|
|
1598
|
+
if (inf.suggestion) lines.push(` Next: ${inf.suggestion}`);
|
|
1599
|
+
}
|
|
1600
|
+
if (result.infractions.length > 20) lines.push(`
|
|
1601
|
+
${result.infractions.length - 20} more findings. Use --format json for the complete report.`);
|
|
1602
|
+
for (const skipped of result.skippedFiles) lines.push(`
|
|
1603
|
+
NOT SCANNED ${terminalText(skipped.file)}: ${terminalText(skipped.reason)}`);
|
|
1604
|
+
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(", ")}.`), "");
|
|
1605
|
+
if (!result.filesInspected) lines.push(" No supported source files inspected.", "");
|
|
1606
|
+
return lines.join("\n");
|
|
1607
|
+
}
|
|
1608
|
+
function markdownCell(value) {
|
|
1609
|
+
return value.replace(/[\\[\]()*_!]/g, "\\$&").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/\|/g, "\\|").replace(/`/g, "`").replace(/[\r\n]/g, " ");
|
|
1610
|
+
}
|
|
1611
|
+
function formatCheckMarkdown(result) {
|
|
1612
|
+
const status = !result.complete ? "INCOMPLETE" : result.passed ? "PASSED" : "BLOCKED";
|
|
1613
|
+
const lines = [
|
|
1614
|
+
`## agent-roast current code check: ${status}`,
|
|
1615
|
+
"",
|
|
1616
|
+
`${result.filesInspected} files, ${result.linesInspected} lines inspected. ${result.infractions.length} findings to review; ${result.baselineCount} accepted baseline findings.`,
|
|
1617
|
+
"",
|
|
1618
|
+
"| Rule | Location | Finding | Next step |",
|
|
1619
|
+
"| --- | --- | --- | --- |"
|
|
1620
|
+
];
|
|
1621
|
+
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.")} |`);
|
|
1622
|
+
if (result.staleBaselineCount) lines.push("", `${result.staleBaselineCount} baseline findings no longer present (or now disabled/ignored).`);
|
|
1623
|
+
for (const skipped of result.skippedFiles) lines.push("", `Not scanned: ${markdownCell(skipped.file)} \u2014 ${markdownCell(skipped.reason)}`);
|
|
1624
|
+
return lines.join("\n");
|
|
1625
|
+
}
|
|
1453
1626
|
|
|
1454
1627
|
// src/init.ts
|
|
1455
1628
|
import * as fs2 from "fs";
|
|
1456
1629
|
import * as path2 from "path";
|
|
1457
1630
|
var DEFAULT_CONFIG_TEMPLATE = {
|
|
1458
|
-
$schema: "https://raw.githubusercontent.com/Nari-Ab/agent-roast/main/schema.json",
|
|
1459
1631
|
ignorePaths: [
|
|
1460
1632
|
"**/*.min.js",
|
|
1461
1633
|
"**/vendor/**",
|
|
@@ -1463,21 +1635,16 @@ var DEFAULT_CONFIG_TEMPLATE = {
|
|
|
1463
1635
|
"**/build/**",
|
|
1464
1636
|
"**/fixtures/**"
|
|
1465
1637
|
],
|
|
1466
|
-
rules: {
|
|
1467
|
-
"hallucinated-package": "error",
|
|
1468
|
-
"empty-catch": "warn",
|
|
1469
|
-
"trivial-assert": "warn",
|
|
1470
|
-
"stub-abandonment": "warn",
|
|
1471
|
-
"mock-leak": "warn"
|
|
1472
|
-
},
|
|
1638
|
+
rules: { ...DEFAULT_CONFIG.rules },
|
|
1473
1639
|
scoreThreshold: 70
|
|
1474
1640
|
};
|
|
1475
1641
|
var GITHUB_WORKFLOW_TEMPLATE = `name: Agent Roast
|
|
1476
1642
|
on:
|
|
1477
1643
|
pull_request:
|
|
1478
1644
|
branches: [main, master]
|
|
1479
|
-
|
|
1480
|
-
|
|
1645
|
+
|
|
1646
|
+
permissions:
|
|
1647
|
+
contents: read
|
|
1481
1648
|
|
|
1482
1649
|
jobs:
|
|
1483
1650
|
audit:
|
|
@@ -1489,22 +1656,17 @@ jobs:
|
|
|
1489
1656
|
with:
|
|
1490
1657
|
fetch-depth: 0
|
|
1491
1658
|
|
|
1492
|
-
- name: Setup Node.js
|
|
1493
|
-
uses: actions/setup-node@v4
|
|
1494
|
-
with:
|
|
1495
|
-
node-version: 20
|
|
1496
|
-
|
|
1497
1659
|
- name: Run agent-roast PR check
|
|
1498
1660
|
if: github.event_name == 'pull_request'
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1661
|
+
uses: Nari-Ab/agent-roast@v1
|
|
1662
|
+
with:
|
|
1663
|
+
base: \${{ github.event.pull_request.base.sha }}
|
|
1664
|
+
head: \${{ github.event.pull_request.head.sha }}
|
|
1665
|
+
format: github
|
|
1504
1666
|
`;
|
|
1505
1667
|
var PRE_COMMIT_HOOK_TEMPLATE = `#!/bin/sh
|
|
1506
1668
|
# Run agent-roast pre-commit verification
|
|
1507
|
-
npx agent-roast --
|
|
1669
|
+
npx --no-install agent-roast --staged
|
|
1508
1670
|
`;
|
|
1509
1671
|
async function initProject(options = {}) {
|
|
1510
1672
|
const cwd = path2.resolve(options.cwd || process.cwd());
|
|
@@ -1512,7 +1674,7 @@ async function initProject(options = {}) {
|
|
|
1512
1674
|
const skipped = [];
|
|
1513
1675
|
const configPath = path2.join(cwd, ".agentroastrc.json");
|
|
1514
1676
|
const altConfigPath = path2.join(cwd, "agent-roast.config.json");
|
|
1515
|
-
if (fs2.existsSync(configPath) || fs2.existsSync(altConfigPath)) {
|
|
1677
|
+
if (fs2.existsSync(configPath) || fs2.existsSync(altConfigPath) || fs2.existsSync(path2.join(cwd, ".agentroastrc"))) {
|
|
1516
1678
|
skipped.push(".agentroastrc.json");
|
|
1517
1679
|
} else {
|
|
1518
1680
|
fs2.writeFileSync(
|
|
@@ -1613,6 +1775,230 @@ function generateShieldsJson(score, label = "AI Discipline") {
|
|
|
1613
1775
|
};
|
|
1614
1776
|
}
|
|
1615
1777
|
|
|
1778
|
+
// src/check.ts
|
|
1779
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
1780
|
+
import * as fs4 from "fs/promises";
|
|
1781
|
+
import { constants as constants2 } from "fs";
|
|
1782
|
+
import path4 from "path";
|
|
1783
|
+
|
|
1784
|
+
// src/baseline.ts
|
|
1785
|
+
import { createHash, randomUUID } from "crypto";
|
|
1786
|
+
import * as fs3 from "fs";
|
|
1787
|
+
import path3 from "path";
|
|
1788
|
+
function findingFingerprint(finding) {
|
|
1789
|
+
return createHash("sha256").update(JSON.stringify([finding.type, finding.file, finding.lineSnippet?.trim()])).digest("hex");
|
|
1790
|
+
}
|
|
1791
|
+
function createBaseline(findings) {
|
|
1792
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1793
|
+
for (const finding of findings) {
|
|
1794
|
+
const fingerprint = findingFingerprint(finding);
|
|
1795
|
+
counts.set(fingerprint, (counts.get(fingerprint) || 0) + 1);
|
|
1796
|
+
}
|
|
1797
|
+
return {
|
|
1798
|
+
schemaVersion: "1.0.0",
|
|
1799
|
+
fingerprintVersion: 1,
|
|
1800
|
+
entries: [...counts].sort(([a], [b]) => a.localeCompare(b)).map(([fingerprint, count]) => ({ fingerprint, count }))
|
|
1801
|
+
};
|
|
1802
|
+
}
|
|
1803
|
+
function loadBaseline(filename) {
|
|
1804
|
+
try {
|
|
1805
|
+
const stat = fs3.lstatSync(filename);
|
|
1806
|
+
if (!stat.isFile() || stat.size > 5 * 1024 * 1024) throw new Error("Expected a regular baseline file under 5 MiB.");
|
|
1807
|
+
const fd = fs3.openSync(filename, fs3.constants.O_RDONLY | (fs3.constants.O_NOFOLLOW || 0));
|
|
1808
|
+
let content;
|
|
1809
|
+
try {
|
|
1810
|
+
const opened = fs3.fstatSync(fd);
|
|
1811
|
+
if (opened.ino !== stat.ino || opened.dev !== stat.dev) throw new Error("Baseline changed while opening.");
|
|
1812
|
+
const buffer = Buffer.alloc(5 * 1024 * 1024 + 1);
|
|
1813
|
+
let size = 0;
|
|
1814
|
+
while (size < buffer.length) {
|
|
1815
|
+
const read = fs3.readSync(fd, buffer, size, buffer.length - size, null);
|
|
1816
|
+
if (!read) break;
|
|
1817
|
+
size += read;
|
|
1818
|
+
}
|
|
1819
|
+
const after = fs3.fstatSync(fd);
|
|
1820
|
+
if (size > 5 * 1024 * 1024 || size !== after.size || opened.mtimeMs !== after.mtimeMs) throw new Error("Baseline changed or exceeded the size limit.");
|
|
1821
|
+
content = buffer.subarray(0, size).toString("utf8");
|
|
1822
|
+
} finally {
|
|
1823
|
+
fs3.closeSync(fd);
|
|
1824
|
+
}
|
|
1825
|
+
const parsed = JSON.parse(content);
|
|
1826
|
+
if (!parsed || parsed.schemaVersion !== "1.0.0" || parsed.fingerprintVersion !== 1 || !Array.isArray(parsed.entries) || parsed.entries.length > 1e5) {
|
|
1827
|
+
throw new Error("Unsupported baseline schema.");
|
|
1828
|
+
}
|
|
1829
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1830
|
+
for (const entry of parsed.entries) {
|
|
1831
|
+
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)) {
|
|
1832
|
+
throw new Error("Invalid or duplicate baseline entry.");
|
|
1833
|
+
}
|
|
1834
|
+
seen.add(entry.fingerprint);
|
|
1835
|
+
}
|
|
1836
|
+
return parsed;
|
|
1837
|
+
} catch (error) {
|
|
1838
|
+
throw new ConfigError(`Cannot load baseline '${filename}': ${error instanceof Error ? error.message : String(error)}`);
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
function applyBaseline(findings, baseline) {
|
|
1842
|
+
const remaining = new Map(baseline.entries.map((entry) => [entry.fingerprint, entry.count]));
|
|
1843
|
+
const fresh = [];
|
|
1844
|
+
let baselineCount = 0;
|
|
1845
|
+
for (const finding of findings) {
|
|
1846
|
+
const fingerprint = findingFingerprint(finding);
|
|
1847
|
+
const count = remaining.get(fingerprint) || 0;
|
|
1848
|
+
if (count > 0) {
|
|
1849
|
+
remaining.set(fingerprint, count - 1);
|
|
1850
|
+
baselineCount++;
|
|
1851
|
+
} else {
|
|
1852
|
+
fresh.push(finding);
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
return { findings: fresh, baselineCount, staleBaselineCount: [...remaining.values()].reduce((a, b) => a + b, 0) };
|
|
1856
|
+
}
|
|
1857
|
+
function writeBaseline(filename, findings) {
|
|
1858
|
+
const destination = path3.resolve(filename);
|
|
1859
|
+
const temporary = path3.join(path3.dirname(destination), `.agent-roast-${randomUUID()}.tmp`);
|
|
1860
|
+
let fd;
|
|
1861
|
+
try {
|
|
1862
|
+
fd = fs3.openSync(temporary, "wx", 384);
|
|
1863
|
+
fs3.writeFileSync(fd, JSON.stringify(createBaseline(findings), null, 2) + "\n");
|
|
1864
|
+
fs3.fsyncSync(fd);
|
|
1865
|
+
fs3.closeSync(fd);
|
|
1866
|
+
fd = void 0;
|
|
1867
|
+
fs3.linkSync(temporary, destination);
|
|
1868
|
+
} catch (error) {
|
|
1869
|
+
throw new ConfigError(`Cannot write baseline '${filename}' (existing files are preserved): ${error instanceof Error ? error.message : String(error)}`);
|
|
1870
|
+
} finally {
|
|
1871
|
+
if (fd !== void 0) fs3.closeSync(fd);
|
|
1872
|
+
fs3.rmSync(temporary, { force: true });
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
// src/check.ts
|
|
1877
|
+
var MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
1878
|
+
var MAX_SCAN_BYTES = 50 * 1024 * 1024;
|
|
1879
|
+
var GENERATED_PATHS = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/vendor/**", "**/*.min.js", "**/*.map"];
|
|
1880
|
+
function git(cwd, args) {
|
|
1881
|
+
const result = spawnSync2("git", [...HERMETIC_GIT_FLAGS, ...args], {
|
|
1882
|
+
cwd,
|
|
1883
|
+
env: getHermeticGitEnv(),
|
|
1884
|
+
encoding: "utf8",
|
|
1885
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
1886
|
+
windowsHide: true
|
|
1887
|
+
});
|
|
1888
|
+
if (result.error || result.status !== 0) throw new GitError(result.error?.message || result.stderr, result.status);
|
|
1889
|
+
return result.stdout;
|
|
1890
|
+
}
|
|
1891
|
+
function checkAborted(signal) {
|
|
1892
|
+
if (signal?.aborted) throw new ConfigError("Project check cancelled; no complete result was produced.");
|
|
1893
|
+
}
|
|
1894
|
+
function insideRoot(root, filename) {
|
|
1895
|
+
const relative = path4.relative(root, filename);
|
|
1896
|
+
return relative.length > 0 && relative !== ".." && !relative.startsWith(".." + path4.sep) && !path4.isAbsolute(relative);
|
|
1897
|
+
}
|
|
1898
|
+
async function readSource(root, file, signal) {
|
|
1899
|
+
const filename = path4.resolve(root, file);
|
|
1900
|
+
if (!insideRoot(root, filename)) throw new Error("File escapes the repository.");
|
|
1901
|
+
let stat;
|
|
1902
|
+
try {
|
|
1903
|
+
stat = await fs4.lstat(filename);
|
|
1904
|
+
} catch (error) {
|
|
1905
|
+
if (error.code === "ENOENT") return null;
|
|
1906
|
+
throw error;
|
|
1907
|
+
}
|
|
1908
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("Symlinks and special files are not scanned.");
|
|
1909
|
+
const real = await fs4.realpath(filename);
|
|
1910
|
+
if (!insideRoot(root, real)) throw new Error("File resolves outside the repository.");
|
|
1911
|
+
if (stat.size > MAX_FILE_BYTES) throw new Error("File exceeds the 2 MiB scan limit.");
|
|
1912
|
+
const handle = await fs4.open(filename, constants2.O_RDONLY | (constants2.O_NOFOLLOW || 0));
|
|
1913
|
+
try {
|
|
1914
|
+
const opened = await handle.stat();
|
|
1915
|
+
if (opened.ino !== stat.ino || opened.dev !== stat.dev || !opened.isFile()) throw new Error("File changed while opening.");
|
|
1916
|
+
const chunks = [];
|
|
1917
|
+
let size = 0;
|
|
1918
|
+
while (true) {
|
|
1919
|
+
checkAborted(signal);
|
|
1920
|
+
const chunk = Buffer.alloc(Math.min(64 * 1024, MAX_FILE_BYTES + 1 - size));
|
|
1921
|
+
const { bytesRead } = await handle.read(chunk, 0, chunk.length, null);
|
|
1922
|
+
if (!bytesRead) break;
|
|
1923
|
+
size += bytesRead;
|
|
1924
|
+
if (size > MAX_FILE_BYTES) throw new Error("File exceeds the 2 MiB scan limit.");
|
|
1925
|
+
chunks.push(chunk.subarray(0, bytesRead));
|
|
1926
|
+
}
|
|
1927
|
+
const after = await handle.stat();
|
|
1928
|
+
if (after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || size !== after.size) throw new Error("File changed while reading; rerun the check.");
|
|
1929
|
+
return Buffer.concat(chunks);
|
|
1930
|
+
} finally {
|
|
1931
|
+
await handle.close();
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
async function checkProject(options = {}) {
|
|
1935
|
+
checkAborted(options.signal);
|
|
1936
|
+
const cwd = path4.resolve(options.cwd || process.cwd());
|
|
1937
|
+
const root = await fs4.realpath(git(cwd, ["rev-parse", "--show-toplevel"]).trim());
|
|
1938
|
+
const config = loadConfig(root);
|
|
1939
|
+
const failOn = options.failOn ?? config.failOn ?? ["test-skip", "trivial-assert"];
|
|
1940
|
+
for (const rule of failOn) if (!VALID_DETECTORS.includes(rule)) throw new ConfigError(`Unknown rule '${rule}'.`);
|
|
1941
|
+
const format = options.format || "terminal";
|
|
1942
|
+
if (!["terminal", "json", "github", "markdown"].includes(format)) throw new ConfigError("Unsupported check output format.");
|
|
1943
|
+
const baseline = options.baseline ? loadBaseline(path4.resolve(cwd, options.baseline)) : void 0;
|
|
1944
|
+
const filenames = [...new Set(git(root, ["ls-files", "--cached", "--others", "--exclude-standard", "-z"]).split("\0").filter(Boolean))].sort();
|
|
1945
|
+
const infractions = [];
|
|
1946
|
+
const skippedFiles = [];
|
|
1947
|
+
let filesInspected = 0;
|
|
1948
|
+
let linesInspected = 0;
|
|
1949
|
+
let totalBytes = 0;
|
|
1950
|
+
for (const file of filenames) {
|
|
1951
|
+
checkAborted(options.signal);
|
|
1952
|
+
if (!isCodeFile(file, [...GENERATED_PATHS, ...config.ignorePaths || []])) continue;
|
|
1953
|
+
try {
|
|
1954
|
+
const content = await readSource(root, file, options.signal);
|
|
1955
|
+
if (content === null) continue;
|
|
1956
|
+
totalBytes += content.length;
|
|
1957
|
+
if (totalBytes > MAX_SCAN_BYTES) throw new Error("Project exceeds the 50 MiB scan limit.");
|
|
1958
|
+
if (content.includes(0)) throw new Error("Binary content in a source file.");
|
|
1959
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(content);
|
|
1960
|
+
const lines = text.split(/\r?\n/);
|
|
1961
|
+
if (lines[lines.length - 1] === "") lines.pop();
|
|
1962
|
+
const audit = auditFileDiffs([{ path: file, addedLines: lines.map((line, index) => ({ line, lineNumber: index + 1 })) }], "WORKTREE", "", { rules: config.rules });
|
|
1963
|
+
infractions.push(...audit.infractions);
|
|
1964
|
+
filesInspected++;
|
|
1965
|
+
linesInspected += lines.length;
|
|
1966
|
+
} catch (error) {
|
|
1967
|
+
checkAborted(options.signal);
|
|
1968
|
+
skippedFiles.push({ file, reason: error instanceof Error ? error.message : String(error) });
|
|
1969
|
+
if (totalBytes > MAX_SCAN_BYTES) break;
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
checkAborted(options.signal);
|
|
1973
|
+
const applied = baseline ? applyBaseline(infractions, baseline) : { findings: infractions, baselineCount: 0, staleBaselineCount: 0 };
|
|
1974
|
+
const findings = applied.findings;
|
|
1975
|
+
const failedRules = [...new Set(findings.filter((finding) => failOn.includes(finding.type)).map((finding) => finding.type))];
|
|
1976
|
+
const complete = skippedFiles.length === 0;
|
|
1977
|
+
const passed = complete && failedRules.length === 0;
|
|
1978
|
+
const result = {
|
|
1979
|
+
schemaVersion: "1.0.0",
|
|
1980
|
+
mode: "check",
|
|
1981
|
+
filesInspected,
|
|
1982
|
+
linesInspected,
|
|
1983
|
+
totalFindings: infractions.length,
|
|
1984
|
+
infractions: findings,
|
|
1985
|
+
baselineCount: applied.baselineCount,
|
|
1986
|
+
staleBaselineCount: applied.staleBaselineCount,
|
|
1987
|
+
skippedFiles,
|
|
1988
|
+
complete,
|
|
1989
|
+
failedRules,
|
|
1990
|
+
passed,
|
|
1991
|
+
exitCode: complete ? passed ? 0 : 1 : 2,
|
|
1992
|
+
output: ""
|
|
1993
|
+
};
|
|
1994
|
+
if (format === "json") result.output = JSON.stringify(result, null, 2);
|
|
1995
|
+
else if (format === "markdown") result.output = formatCheckMarkdown(result);
|
|
1996
|
+
else if (format === "github") {
|
|
1997
|
+
result.output = formatGithubAnnotations(result) + (complete ? "" : "\n::error::Project scan incomplete. See the JSON or terminal report for skipped files.");
|
|
1998
|
+
} else result.output = formatCheckTerminal(result);
|
|
1999
|
+
return result;
|
|
2000
|
+
}
|
|
2001
|
+
|
|
1616
2002
|
// src/index.ts
|
|
1617
2003
|
var InvalidDetectorError = class extends Error {
|
|
1618
2004
|
constructor(message) {
|
|
@@ -1621,10 +2007,10 @@ var InvalidDetectorError = class extends Error {
|
|
|
1621
2007
|
}
|
|
1622
2008
|
};
|
|
1623
2009
|
async function roastRepository(options = {}) {
|
|
1624
|
-
const cwd =
|
|
2010
|
+
const cwd = path5.resolve(options.cwd || process.cwd());
|
|
1625
2011
|
const since = options.since || "90 days ago";
|
|
1626
|
-
const repoName =
|
|
1627
|
-
const collector = new MetricsCollector({ all: options.all, ignorePaths: options.ignorePaths });
|
|
2012
|
+
const repoName = path5.basename(cwd);
|
|
2013
|
+
const collector = new MetricsCollector({ all: options.all, ignorePaths: options.ignorePaths, rules: options.rules, panicLoopWindowMinutes: options.panicLoopWindowMinutes });
|
|
1628
2014
|
const gitStream = streamGitLog(cwd, since);
|
|
1629
2015
|
await parseGitLogStream(gitStream, (commit, stats) => {
|
|
1630
2016
|
collector.processCommit(commit);
|
|
@@ -1638,7 +2024,7 @@ async function roastRepository(options = {}) {
|
|
|
1638
2024
|
return { summary, output };
|
|
1639
2025
|
}
|
|
1640
2026
|
async function auditPullRequest(options) {
|
|
1641
|
-
const cwd =
|
|
2027
|
+
const cwd = path5.resolve(options.cwd || process.cwd());
|
|
1642
2028
|
const format = options.format || "terminal";
|
|
1643
2029
|
const failOn = (options.failOn || ["test-skip"]).map((s) => s.trim()).filter(Boolean);
|
|
1644
2030
|
for (const det of failOn) {
|
|
@@ -1648,10 +2034,16 @@ async function auditPullRequest(options) {
|
|
|
1648
2034
|
);
|
|
1649
2035
|
}
|
|
1650
2036
|
}
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
2037
|
+
if (options.staged && options.base) {
|
|
2038
|
+
throw new GitResolutionError("--staged cannot be combined with --base.");
|
|
2039
|
+
}
|
|
2040
|
+
if (!options.staged && !options.base) {
|
|
2041
|
+
throw new GitResolutionError("A base ref is required for a pull request audit.");
|
|
2042
|
+
}
|
|
2043
|
+
const baseSha = options.staged ? "INDEX" : resolveCommitSha(cwd, options.base, "Base ref");
|
|
2044
|
+
const headSha = options.staged ? "INDEX" : resolveCommitSha(cwd, options.head || "HEAD", "Head ref");
|
|
2045
|
+
const mergeBaseSha = options.staged ? "INDEX" : findMergeBase(cwd, baseSha, headSha);
|
|
2046
|
+
const diffStream = streamGitDiff(cwd, mergeBaseSha, headSha, options.staged);
|
|
1655
2047
|
let rawDiff = "";
|
|
1656
2048
|
await new Promise((resolve2, reject) => {
|
|
1657
2049
|
diffStream.on("data", (chunk) => {
|
|
@@ -1661,7 +2053,7 @@ async function auditPullRequest(options) {
|
|
|
1661
2053
|
diffStream.on("error", reject);
|
|
1662
2054
|
});
|
|
1663
2055
|
const { files, skippedLongLines, skippedUnknownFiles } = parseDiff(rawDiff);
|
|
1664
|
-
const audit = auditFileDiffs(files, headSha, (/* @__PURE__ */ new Date()).toISOString(), { ignorePaths: options.ignorePaths });
|
|
2056
|
+
const audit = auditFileDiffs(files, headSha, (/* @__PURE__ */ new Date()).toISOString(), { ignorePaths: options.ignorePaths, rules: options.rules });
|
|
1665
2057
|
const infractions = audit.infractions;
|
|
1666
2058
|
const defectDensityPer100Lines = audit.linesAdded > 0 ? Math.round(infractions.length / audit.linesAdded * 100 * 100) / 100 : 0;
|
|
1667
2059
|
const failedRules = [...new Set(
|
|
@@ -1671,6 +2063,7 @@ async function auditPullRequest(options) {
|
|
|
1671
2063
|
const exitCode = passed ? 0 : 1;
|
|
1672
2064
|
const result = {
|
|
1673
2065
|
schemaVersion: "1.0.0",
|
|
2066
|
+
mode: options.staged ? "staged" : "pr",
|
|
1674
2067
|
baseSha,
|
|
1675
2068
|
headSha,
|
|
1676
2069
|
mergeBaseSha,
|
|
@@ -1713,7 +2106,10 @@ export {
|
|
|
1713
2106
|
resolveCommitSha,
|
|
1714
2107
|
findMergeBase,
|
|
1715
2108
|
GitResolutionError,
|
|
2109
|
+
RULE_GUIDES,
|
|
2110
|
+
VALID_DETECTORS,
|
|
1716
2111
|
DEFAULT_CONFIG,
|
|
2112
|
+
ConfigError,
|
|
1717
2113
|
loadConfig,
|
|
1718
2114
|
matchIgnorePath,
|
|
1719
2115
|
isCodeFile,
|
|
@@ -1727,7 +2123,8 @@ export {
|
|
|
1727
2123
|
formatGithubAnnotations,
|
|
1728
2124
|
formatGithubStepSummary,
|
|
1729
2125
|
formatPrMarkdownComment,
|
|
1730
|
-
|
|
2126
|
+
formatCheckTerminal,
|
|
2127
|
+
formatCheckMarkdown,
|
|
1731
2128
|
DEFAULT_CONFIG_TEMPLATE,
|
|
1732
2129
|
GITHUB_WORKFLOW_TEMPLATE,
|
|
1733
2130
|
PRE_COMMIT_HOOK_TEMPLATE,
|
|
@@ -1736,6 +2133,12 @@ export {
|
|
|
1736
2133
|
generateSvgBadge,
|
|
1737
2134
|
generateShieldsMarkdown,
|
|
1738
2135
|
generateShieldsJson,
|
|
2136
|
+
findingFingerprint,
|
|
2137
|
+
createBaseline,
|
|
2138
|
+
loadBaseline,
|
|
2139
|
+
applyBaseline,
|
|
2140
|
+
writeBaseline,
|
|
2141
|
+
checkProject,
|
|
1739
2142
|
InvalidDetectorError,
|
|
1740
2143
|
roastRepository,
|
|
1741
2144
|
auditPullRequest
|