agent-roast 0.3.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 +120 -16
- package/dist/{chunk-SMZZAGFS.js → chunk-FUAUIURY.js} +734 -223
- package/dist/cli.js +126 -15
- package/dist/index.d.ts +104 -23
- package/dist/index.js +33 -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(", ")})`;
|
|
@@ -1397,22 +1539,95 @@ function formatGithubStepSummary(result) {
|
|
|
1397
1539
|
}
|
|
1398
1540
|
return lines.join("\n");
|
|
1399
1541
|
}
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
"
|
|
1404
|
-
|
|
1405
|
-
"
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1542
|
+
function formatPrMarkdownComment(result) {
|
|
1543
|
+
const lines = [];
|
|
1544
|
+
const titleEmoji = result.passed ? "\u2705" : "\u274C";
|
|
1545
|
+
const titleStatus = result.passed ? "PASSED" : "BLOCKED";
|
|
1546
|
+
lines.push(`## \u{1F6E1}\uFE0F agent-roast audit: ${titleStatus} ${titleEmoji}`);
|
|
1547
|
+
lines.push("");
|
|
1548
|
+
if (!result.hasSupportedChanges) {
|
|
1549
|
+
lines.push("No supported source file changes detected in the audited diff.");
|
|
1550
|
+
lines.push("");
|
|
1551
|
+
lines.push("---");
|
|
1552
|
+
lines.push("*Audited 100% offline with [agent-roast](https://github.com/Nari-Ab/agent-roast)*");
|
|
1553
|
+
return lines.join("\n");
|
|
1554
|
+
}
|
|
1555
|
+
const statusText = result.passed ? "\u2705 Passed" : `\u274C Blocked on ${result.failedRules.join(", ")}`;
|
|
1556
|
+
lines.push("| Status | Defect Density | Files Inspected | Lines Added | Infractions |");
|
|
1557
|
+
lines.push("| :--- | :--- | :--- | :--- | :--- |");
|
|
1558
|
+
lines.push(
|
|
1559
|
+
`| ${statusText} | ${result.defectDensityPer100Lines} / 100 LOC | ${result.filesInspected} | +${result.linesAdded} | ${result.infractions.length} |`
|
|
1560
|
+
);
|
|
1561
|
+
lines.push("");
|
|
1562
|
+
if (result.infractions.length > 0) {
|
|
1563
|
+
lines.push("<details>");
|
|
1564
|
+
lines.push(`<summary><b>View Detected Infractions (${result.infractions.length})</b></summary>`);
|
|
1565
|
+
lines.push("");
|
|
1566
|
+
lines.push("| Type | File:Line | Description | Snippet |");
|
|
1567
|
+
lines.push("| :--- | :--- | :--- | :--- |");
|
|
1568
|
+
const sorted = sortInfractionsDeterministic(result.infractions);
|
|
1569
|
+
for (const inf of sorted) {
|
|
1570
|
+
const loc = inf.file ? `\`${inf.file}:${inf.lineNumber || 1}\`` : "*history*";
|
|
1571
|
+
const snippet = inf.lineSnippet ? `\`${inf.lineSnippet.replace(/\|/g, "\\|")}\`` : "-";
|
|
1572
|
+
lines.push(`| \`${inf.type}\` | ${loc} | ${inf.reason} | ${snippet} |`);
|
|
1573
|
+
}
|
|
1574
|
+
lines.push("");
|
|
1575
|
+
lines.push("</details>");
|
|
1576
|
+
lines.push("");
|
|
1577
|
+
} else {
|
|
1578
|
+
lines.push("\u2728 **Clean pull request**: Zero AI shortcuts, bypassed tests, or compiler type escapes detected.");
|
|
1579
|
+
lines.push("");
|
|
1580
|
+
}
|
|
1581
|
+
lines.push("---");
|
|
1582
|
+
lines.push("*Audited 100% offline with [agent-roast](https://github.com/Nari-Ab/agent-roast)*");
|
|
1583
|
+
return lines.join("\n");
|
|
1584
|
+
}
|
|
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
|
+
}
|
|
1410
1626
|
|
|
1411
1627
|
// src/init.ts
|
|
1412
1628
|
import * as fs2 from "fs";
|
|
1413
1629
|
import * as path2 from "path";
|
|
1414
1630
|
var DEFAULT_CONFIG_TEMPLATE = {
|
|
1415
|
-
$schema: "https://raw.githubusercontent.com/sidzuya/agent-roast/main/schema.json",
|
|
1416
1631
|
ignorePaths: [
|
|
1417
1632
|
"**/*.min.js",
|
|
1418
1633
|
"**/vendor/**",
|
|
@@ -1420,21 +1635,16 @@ var DEFAULT_CONFIG_TEMPLATE = {
|
|
|
1420
1635
|
"**/build/**",
|
|
1421
1636
|
"**/fixtures/**"
|
|
1422
1637
|
],
|
|
1423
|
-
rules: {
|
|
1424
|
-
"hallucinated-package": "error",
|
|
1425
|
-
"empty-catch": "warn",
|
|
1426
|
-
"trivial-assert": "warn",
|
|
1427
|
-
"stub-abandonment": "warn",
|
|
1428
|
-
"mock-leak": "warn"
|
|
1429
|
-
},
|
|
1638
|
+
rules: { ...DEFAULT_CONFIG.rules },
|
|
1430
1639
|
scoreThreshold: 70
|
|
1431
1640
|
};
|
|
1432
1641
|
var GITHUB_WORKFLOW_TEMPLATE = `name: Agent Roast
|
|
1433
1642
|
on:
|
|
1434
1643
|
pull_request:
|
|
1435
1644
|
branches: [main, master]
|
|
1436
|
-
|
|
1437
|
-
|
|
1645
|
+
|
|
1646
|
+
permissions:
|
|
1647
|
+
contents: read
|
|
1438
1648
|
|
|
1439
1649
|
jobs:
|
|
1440
1650
|
audit:
|
|
@@ -1446,22 +1656,17 @@ jobs:
|
|
|
1446
1656
|
with:
|
|
1447
1657
|
fetch-depth: 0
|
|
1448
1658
|
|
|
1449
|
-
- name: Setup Node.js
|
|
1450
|
-
uses: actions/setup-node@v4
|
|
1451
|
-
with:
|
|
1452
|
-
node-version: 20
|
|
1453
|
-
|
|
1454
1659
|
- name: Run agent-roast PR check
|
|
1455
1660
|
if: github.event_name == 'pull_request'
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
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
|
|
1461
1666
|
`;
|
|
1462
1667
|
var PRE_COMMIT_HOOK_TEMPLATE = `#!/bin/sh
|
|
1463
1668
|
# Run agent-roast pre-commit verification
|
|
1464
|
-
npx agent-roast --
|
|
1669
|
+
npx --no-install agent-roast --staged
|
|
1465
1670
|
`;
|
|
1466
1671
|
async function initProject(options = {}) {
|
|
1467
1672
|
const cwd = path2.resolve(options.cwd || process.cwd());
|
|
@@ -1469,7 +1674,7 @@ async function initProject(options = {}) {
|
|
|
1469
1674
|
const skipped = [];
|
|
1470
1675
|
const configPath = path2.join(cwd, ".agentroastrc.json");
|
|
1471
1676
|
const altConfigPath = path2.join(cwd, "agent-roast.config.json");
|
|
1472
|
-
if (fs2.existsSync(configPath) || fs2.existsSync(altConfigPath)) {
|
|
1677
|
+
if (fs2.existsSync(configPath) || fs2.existsSync(altConfigPath) || fs2.existsSync(path2.join(cwd, ".agentroastrc"))) {
|
|
1473
1678
|
skipped.push(".agentroastrc.json");
|
|
1474
1679
|
} else {
|
|
1475
1680
|
fs2.writeFileSync(
|
|
@@ -1512,6 +1717,288 @@ async function initProject(options = {}) {
|
|
|
1512
1717
|
return { created, skipped };
|
|
1513
1718
|
}
|
|
1514
1719
|
|
|
1720
|
+
// src/badge.ts
|
|
1721
|
+
function getScoreColor(score) {
|
|
1722
|
+
if (score === null || isNaN(score)) {
|
|
1723
|
+
return { name: "lightgrey", hex: "#9f9f9f" };
|
|
1724
|
+
}
|
|
1725
|
+
if (score >= 90) return { name: "brightgreen", hex: "#4c1" };
|
|
1726
|
+
if (score >= 80) return { name: "green", hex: "#97ca00" };
|
|
1727
|
+
if (score >= 70) return { name: "yellowgreen", hex: "#a4a61d" };
|
|
1728
|
+
if (score >= 60) return { name: "yellow", hex: "#dfb317" };
|
|
1729
|
+
return { name: "red", hex: "#e05d44" };
|
|
1730
|
+
}
|
|
1731
|
+
function generateSvgBadge(score, label = "AI Discipline") {
|
|
1732
|
+
const value = score !== null && !isNaN(score) ? `${score}/100` : "N/A";
|
|
1733
|
+
const color = getScoreColor(score);
|
|
1734
|
+
const labelWidth = Math.round(label.length * 6.8 + 14);
|
|
1735
|
+
const valueWidth = Math.round(value.length * 7.2 + 14);
|
|
1736
|
+
const totalWidth = labelWidth + valueWidth;
|
|
1737
|
+
const labelX = Math.round(labelWidth / 2);
|
|
1738
|
+
const valueX = Math.round(labelWidth + valueWidth / 2);
|
|
1739
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="20" role="img" aria-label="${label}: ${value}">
|
|
1740
|
+
<linearGradient id="s" x2="0" y2="100%">
|
|
1741
|
+
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
|
1742
|
+
<stop offset="1" stop-opacity=".1"/>
|
|
1743
|
+
</linearGradient>
|
|
1744
|
+
<clipPath id="r">
|
|
1745
|
+
<rect width="${totalWidth}" height="20" rx="3" fill="#fff"/>
|
|
1746
|
+
</clipPath>
|
|
1747
|
+
<g clip-path="url(#r)">
|
|
1748
|
+
<rect width="${labelWidth}" height="20" fill="#555"/>
|
|
1749
|
+
<rect x="${labelWidth}" width="${valueWidth}" height="20" fill="${color.hex}"/>
|
|
1750
|
+
<rect width="${totalWidth}" height="20" fill="url(#s)"/>
|
|
1751
|
+
</g>
|
|
1752
|
+
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110">
|
|
1753
|
+
<text aria-hidden="true" x="${labelX * 10}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${(labelWidth - 14) * 10}">${label}</text>
|
|
1754
|
+
<text x="${labelX * 10}" y="140" transform="scale(.1)" fill="#fff" textLength="${(labelWidth - 14) * 10}">${label}</text>
|
|
1755
|
+
<text aria-hidden="true" x="${valueX * 10}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${(valueWidth - 14) * 10}">${value}</text>
|
|
1756
|
+
<text x="${valueX * 10}" y="140" transform="scale(.1)" fill="#fff" textLength="${(valueWidth - 14) * 10}">${value}</text>
|
|
1757
|
+
</g>
|
|
1758
|
+
</svg>`;
|
|
1759
|
+
}
|
|
1760
|
+
function generateShieldsMarkdown(score, label = "AI Discipline", repoUrl = "https://github.com/Nari-Ab/agent-roast") {
|
|
1761
|
+
const value = score !== null && !isNaN(score) ? `${score}%2F100` : "N%2FA";
|
|
1762
|
+
const color = getScoreColor(score);
|
|
1763
|
+
const encodedLabel = encodeURIComponent(label);
|
|
1764
|
+
const badgeUrl = `https://img.shields.io/badge/${encodedLabel}-${value}-${color.name}?style=flat-square`;
|
|
1765
|
+
return `[](${repoUrl})`;
|
|
1766
|
+
}
|
|
1767
|
+
function generateShieldsJson(score, label = "AI Discipline") {
|
|
1768
|
+
const value = score !== null && !isNaN(score) ? `${score}/100` : "N/A";
|
|
1769
|
+
const color = getScoreColor(score);
|
|
1770
|
+
return {
|
|
1771
|
+
schemaVersion: 1,
|
|
1772
|
+
label,
|
|
1773
|
+
message: value,
|
|
1774
|
+
color: color.name
|
|
1775
|
+
};
|
|
1776
|
+
}
|
|
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
|
+
|
|
1515
2002
|
// src/index.ts
|
|
1516
2003
|
var InvalidDetectorError = class extends Error {
|
|
1517
2004
|
constructor(message) {
|
|
@@ -1520,10 +2007,10 @@ var InvalidDetectorError = class extends Error {
|
|
|
1520
2007
|
}
|
|
1521
2008
|
};
|
|
1522
2009
|
async function roastRepository(options = {}) {
|
|
1523
|
-
const cwd =
|
|
2010
|
+
const cwd = path5.resolve(options.cwd || process.cwd());
|
|
1524
2011
|
const since = options.since || "90 days ago";
|
|
1525
|
-
const repoName =
|
|
1526
|
-
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 });
|
|
1527
2014
|
const gitStream = streamGitLog(cwd, since);
|
|
1528
2015
|
await parseGitLogStream(gitStream, (commit, stats) => {
|
|
1529
2016
|
collector.processCommit(commit);
|
|
@@ -1537,7 +2024,7 @@ async function roastRepository(options = {}) {
|
|
|
1537
2024
|
return { summary, output };
|
|
1538
2025
|
}
|
|
1539
2026
|
async function auditPullRequest(options) {
|
|
1540
|
-
const cwd =
|
|
2027
|
+
const cwd = path5.resolve(options.cwd || process.cwd());
|
|
1541
2028
|
const format = options.format || "terminal";
|
|
1542
2029
|
const failOn = (options.failOn || ["test-skip"]).map((s) => s.trim()).filter(Boolean);
|
|
1543
2030
|
for (const det of failOn) {
|
|
@@ -1547,10 +2034,16 @@ async function auditPullRequest(options) {
|
|
|
1547
2034
|
);
|
|
1548
2035
|
}
|
|
1549
2036
|
}
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
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);
|
|
1554
2047
|
let rawDiff = "";
|
|
1555
2048
|
await new Promise((resolve2, reject) => {
|
|
1556
2049
|
diffStream.on("data", (chunk) => {
|
|
@@ -1560,7 +2053,7 @@ async function auditPullRequest(options) {
|
|
|
1560
2053
|
diffStream.on("error", reject);
|
|
1561
2054
|
});
|
|
1562
2055
|
const { files, skippedLongLines, skippedUnknownFiles } = parseDiff(rawDiff);
|
|
1563
|
-
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 });
|
|
1564
2057
|
const infractions = audit.infractions;
|
|
1565
2058
|
const defectDensityPer100Lines = audit.linesAdded > 0 ? Math.round(infractions.length / audit.linesAdded * 100 * 100) / 100 : 0;
|
|
1566
2059
|
const failedRules = [...new Set(
|
|
@@ -1570,6 +2063,7 @@ async function auditPullRequest(options) {
|
|
|
1570
2063
|
const exitCode = passed ? 0 : 1;
|
|
1571
2064
|
const result = {
|
|
1572
2065
|
schemaVersion: "1.0.0",
|
|
2066
|
+
mode: options.staged ? "staged" : "pr",
|
|
1573
2067
|
baseSha,
|
|
1574
2068
|
headSha,
|
|
1575
2069
|
mergeBaseSha,
|
|
@@ -1589,6 +2083,8 @@ async function auditPullRequest(options) {
|
|
|
1589
2083
|
result.output = JSON.stringify(result, null, 2);
|
|
1590
2084
|
} else if (format === "github") {
|
|
1591
2085
|
result.output = formatGithubAnnotations(result);
|
|
2086
|
+
} else if (format === "markdown") {
|
|
2087
|
+
result.output = formatPrMarkdownComment(result);
|
|
1592
2088
|
} else {
|
|
1593
2089
|
result.output = formatPrTerminalCard(result);
|
|
1594
2090
|
}
|
|
@@ -1610,7 +2106,10 @@ export {
|
|
|
1610
2106
|
resolveCommitSha,
|
|
1611
2107
|
findMergeBase,
|
|
1612
2108
|
GitResolutionError,
|
|
2109
|
+
RULE_GUIDES,
|
|
2110
|
+
VALID_DETECTORS,
|
|
1613
2111
|
DEFAULT_CONFIG,
|
|
2112
|
+
ConfigError,
|
|
1614
2113
|
loadConfig,
|
|
1615
2114
|
matchIgnorePath,
|
|
1616
2115
|
isCodeFile,
|
|
@@ -1623,11 +2122,23 @@ export {
|
|
|
1623
2122
|
formatPrTerminalCard,
|
|
1624
2123
|
formatGithubAnnotations,
|
|
1625
2124
|
formatGithubStepSummary,
|
|
1626
|
-
|
|
2125
|
+
formatPrMarkdownComment,
|
|
2126
|
+
formatCheckTerminal,
|
|
2127
|
+
formatCheckMarkdown,
|
|
1627
2128
|
DEFAULT_CONFIG_TEMPLATE,
|
|
1628
2129
|
GITHUB_WORKFLOW_TEMPLATE,
|
|
1629
2130
|
PRE_COMMIT_HOOK_TEMPLATE,
|
|
1630
2131
|
initProject,
|
|
2132
|
+
getScoreColor,
|
|
2133
|
+
generateSvgBadge,
|
|
2134
|
+
generateShieldsMarkdown,
|
|
2135
|
+
generateShieldsJson,
|
|
2136
|
+
findingFingerprint,
|
|
2137
|
+
createBaseline,
|
|
2138
|
+
loadBaseline,
|
|
2139
|
+
applyBaseline,
|
|
2140
|
+
writeBaseline,
|
|
2141
|
+
checkProject,
|
|
1631
2142
|
InvalidDetectorError,
|
|
1632
2143
|
roastRepository,
|
|
1633
2144
|
auditPullRequest
|