ai-project-maintainer 0.3.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/LICENSE +21 -0
- package/README.md +175 -0
- package/ai-project-maintainer/SKILL.md +62 -0
- package/ai-project-maintainer/agents/openai.yaml +6 -0
- package/ai-project-maintainer/references/ci-guardrails.md +55 -0
- package/ai-project-maintainer/references/database.md +60 -0
- package/ai-project-maintainer/references/electron-desktop.md +43 -0
- package/ai-project-maintainer/references/incident-response.md +52 -0
- package/ai-project-maintainer/references/local-gate.md +117 -0
- package/ai-project-maintainer/references/security.md +48 -0
- package/ai-project-maintainer/references/tool-router.md +53 -0
- package/ai-project-maintainer/scripts/audit-plan.mjs +155 -0
- package/ai-project-maintainer/scripts/bootstrap-local-tools.ps1 +109 -0
- package/ai-project-maintainer/scripts/check-syntax.mjs +41 -0
- package/ai-project-maintainer/scripts/ci-smoke-gate.mjs +26 -0
- package/ai-project-maintainer/scripts/cli.mjs +165 -0
- package/ai-project-maintainer/scripts/doctor.mjs +80 -0
- package/ai-project-maintainer/scripts/init-audit.mjs +105 -0
- package/ai-project-maintainer/scripts/init-project.mjs +229 -0
- package/ai-project-maintainer/scripts/lib/check-registry.mjs +68 -0
- package/ai-project-maintainer/scripts/lib/checks.mjs +337 -0
- package/ai-project-maintainer/scripts/lib/command-runner.mjs +130 -0
- package/ai-project-maintainer/scripts/lib/intake.mjs +172 -0
- package/ai-project-maintainer/scripts/lib/policy.mjs +150 -0
- package/ai-project-maintainer/scripts/lib/project-detect.mjs +111 -0
- package/ai-project-maintainer/scripts/lib/report.mjs +227 -0
- package/ai-project-maintainer/scripts/probe-project.mjs +218 -0
- package/ai-project-maintainer/scripts/report-summary.mjs +25 -0
- package/ai-project-maintainer/scripts/run-local-gate.mjs +147 -0
- package/docs/CI-GITHUB-ACTIONS.zh-CN.md +83 -0
- package/docs/DEMO.md +81 -0
- package/docs/DEMO.zh-CN.md +81 -0
- package/docs/GITHUB-LAUNCH-CHECKLIST.md +77 -0
- package/docs/INSTALL.zh-CN.md +112 -0
- package/docs/INTAKE-SCHEMA.zh-CN.md +105 -0
- package/docs/POLICY-AND-EXCEPTIONS.zh-CN.md +96 -0
- package/docs/PRODUCTION-AUDIT.zh-CN.md +89 -0
- package/docs/PROMOTION.md +116 -0
- package/docs/UPGRADE-ROADMAP.zh-CN.md +47 -0
- package/docs/demo-output/security-report.md +57 -0
- package/docs/superpowers/plans/2026-06-29-ci-dogfooding.md +200 -0
- package/package.json +21 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import YAML from "yaml";
|
|
4
|
+
|
|
5
|
+
export const defaultPolicy = {
|
|
6
|
+
profile: "oss",
|
|
7
|
+
mode: "strict",
|
|
8
|
+
tool_groups: [],
|
|
9
|
+
checks: {
|
|
10
|
+
gitleaks: "block",
|
|
11
|
+
trivy: "block",
|
|
12
|
+
semgrep: "block",
|
|
13
|
+
"osv-scanner": "warn",
|
|
14
|
+
syft: "warn",
|
|
15
|
+
grype: "warn",
|
|
16
|
+
actionlint: "block",
|
|
17
|
+
zizmor: "warn",
|
|
18
|
+
checkov: "warn",
|
|
19
|
+
"trivy-config": "warn",
|
|
20
|
+
scorecard: "warn",
|
|
21
|
+
megalinter: "warn",
|
|
22
|
+
"pre-commit": "warn",
|
|
23
|
+
},
|
|
24
|
+
fail_on: {
|
|
25
|
+
tests: true,
|
|
26
|
+
secrets: true,
|
|
27
|
+
dependency_high_or_critical: true,
|
|
28
|
+
semgrep_blocking: true,
|
|
29
|
+
trivy_unavailable: true,
|
|
30
|
+
electron_dangerous_settings: true,
|
|
31
|
+
ci_security_high: true,
|
|
32
|
+
},
|
|
33
|
+
warn_on: {
|
|
34
|
+
dev_dependency_vulnerabilities: true,
|
|
35
|
+
missing_optional_tools: true,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function parseYamlFile(filePath, fallback) {
|
|
40
|
+
try {
|
|
41
|
+
const text = fs.readFileSync(filePath, "utf8").replace(/^\uFEFF/, "");
|
|
42
|
+
return YAML.parse(text) || fallback;
|
|
43
|
+
} catch {
|
|
44
|
+
return fallback;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function mergePolicy(customPolicy) {
|
|
49
|
+
return {
|
|
50
|
+
...defaultPolicy,
|
|
51
|
+
...customPolicy,
|
|
52
|
+
checks: { ...defaultPolicy.checks, ...(customPolicy.checks || {}) },
|
|
53
|
+
fail_on: { ...defaultPolicy.fail_on, ...(customPolicy.fail_on || {}) },
|
|
54
|
+
warn_on: { ...defaultPolicy.warn_on, ...(customPolicy.warn_on || {}) },
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function loadPolicyBundle(projectRoot) {
|
|
59
|
+
const root = path.resolve(projectRoot);
|
|
60
|
+
const policyPath = path.join(root, ".ai-maintainer", "policy.yml");
|
|
61
|
+
const exceptionsPath = path.join(root, ".ai-maintainer", "exceptions.yml");
|
|
62
|
+
const customPolicy = fs.existsSync(policyPath) ? parseYamlFile(policyPath, {}) : {};
|
|
63
|
+
const exceptionDocument = fs.existsSync(exceptionsPath) ? parseYamlFile(exceptionsPath, { exceptions: [] }) : { exceptions: [] };
|
|
64
|
+
const exceptions = fs.existsSync(exceptionsPath)
|
|
65
|
+
? (Array.isArray(exceptionDocument.exceptions) ? exceptionDocument.exceptions : [])
|
|
66
|
+
: [];
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
policy: mergePolicy(customPolicy),
|
|
70
|
+
exceptions,
|
|
71
|
+
paths: { policyPath, exceptionsPath },
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function validateExceptions(exceptions, now = new Date()) {
|
|
76
|
+
const valid = [];
|
|
77
|
+
const invalid = [];
|
|
78
|
+
const today = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
|
79
|
+
|
|
80
|
+
for (const item of exceptions || []) {
|
|
81
|
+
const missing = ["id", "check", "reason", "expires", "owner"].filter((key) => !item[key]);
|
|
82
|
+
if (missing.length) {
|
|
83
|
+
invalid.push({ ...item, reason: `exception is missing required field(s): ${missing.join(", ")}` });
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const expires = new Date(`${item.expires}T00:00:00Z`);
|
|
88
|
+
if (Number.isNaN(expires.getTime())) {
|
|
89
|
+
invalid.push({ ...item, reason: "exception expires date is invalid" });
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (expires < today) {
|
|
94
|
+
invalid.push({ ...item, reason: `exception expired on ${item.expires}` });
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
valid.push(item);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { valid, invalid };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function matchesException(check, exception) {
|
|
105
|
+
const target = String(exception.check || "").toLowerCase();
|
|
106
|
+
return target === String(check.name || "").toLowerCase() || target === String(check.group || "").toLowerCase();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function policyKeyForCheck(check) {
|
|
110
|
+
if (check.group === "tests") return "tests";
|
|
111
|
+
if (check.group === "secrets") return "secrets";
|
|
112
|
+
if (check.group === "sast") return "semgrep_blocking";
|
|
113
|
+
if (check.group === "electron") return "electron_dangerous_settings";
|
|
114
|
+
if (check.group === "ci-security") return "ci_security_high";
|
|
115
|
+
if (check.group === "dependencies" && check.name?.includes("trivy") && ["error", "missing"].includes(check.status)) return "trivy_unavailable";
|
|
116
|
+
if (check.group === "dependencies" || check.group === "supply-chain") return "dependency_high_or_critical";
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function applyPolicy(checks, bundle, now = new Date()) {
|
|
121
|
+
const validation = validateExceptions(bundle.exceptions, now);
|
|
122
|
+
const applied = checks.map((check) => ({ ...check }));
|
|
123
|
+
const failOn = bundle.policy?.fail_on || {};
|
|
124
|
+
const checkLevels = bundle.policy?.checks || {};
|
|
125
|
+
|
|
126
|
+
for (const check of applied) {
|
|
127
|
+
if (!check.blocking) continue;
|
|
128
|
+
const checkLevel = checkLevels[check.checkId];
|
|
129
|
+
if (checkLevel === "warn" || checkLevel === "off") {
|
|
130
|
+
check.blocking = false;
|
|
131
|
+
check.policyLevel = checkLevel;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const policyKey = policyKeyForCheck(check);
|
|
135
|
+
if (policyKey && failOn[policyKey] === false) {
|
|
136
|
+
check.blocking = false;
|
|
137
|
+
check.policyDowngrade = `fail_on.${policyKey}=false`;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const exception = validation.valid.find((item) => matchesException(check, item));
|
|
141
|
+
if (!exception) continue;
|
|
142
|
+
check.blocking = false;
|
|
143
|
+
check.exception = exception;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
checks: applied,
|
|
148
|
+
invalidExceptions: validation.invalid,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const ignoredDirs = new Set([
|
|
5
|
+
".git",
|
|
6
|
+
".next",
|
|
7
|
+
".turbo",
|
|
8
|
+
"build",
|
|
9
|
+
"coverage",
|
|
10
|
+
"dist",
|
|
11
|
+
"node_modules",
|
|
12
|
+
"target",
|
|
13
|
+
"vendor",
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
export function readJson(filePath) {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function listFiles(root, options = {}) {
|
|
25
|
+
const out = [];
|
|
26
|
+
const maxFiles = options.maxFiles || 7000;
|
|
27
|
+
const maxDepth = options.maxDepth || 8;
|
|
28
|
+
|
|
29
|
+
function walk(dir, depth) {
|
|
30
|
+
if (out.length >= maxFiles || depth > maxDepth) return;
|
|
31
|
+
let entries = [];
|
|
32
|
+
try {
|
|
33
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
34
|
+
} catch {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
for (const entry of entries) {
|
|
39
|
+
if (ignoredDirs.has(entry.name)) continue;
|
|
40
|
+
const full = path.join(dir, entry.name);
|
|
41
|
+
if (entry.isDirectory()) {
|
|
42
|
+
walk(full, depth + 1);
|
|
43
|
+
} else if (entry.isFile()) {
|
|
44
|
+
out.push(path.relative(root, full).replaceAll(path.sep, "/"));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
walk(root, 0);
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function packageManagers(root, files) {
|
|
54
|
+
const managers = [];
|
|
55
|
+
if (files.includes("pnpm-lock.yaml")) managers.push("pnpm");
|
|
56
|
+
if (files.includes("yarn.lock")) managers.push("yarn");
|
|
57
|
+
if (files.includes("bun.lock") || files.includes("bun.lockb")) managers.push("bun");
|
|
58
|
+
if (files.includes("package-lock.json") || fs.existsSync(path.join(root, "package.json"))) managers.push("npm");
|
|
59
|
+
return managers;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function topExtensions(files) {
|
|
63
|
+
const counts = new Map();
|
|
64
|
+
for (const file of files) {
|
|
65
|
+
const ext = path.extname(file).toLowerCase() || "(none)";
|
|
66
|
+
counts.set(ext, (counts.get(ext) || 0) + 1);
|
|
67
|
+
}
|
|
68
|
+
return [...counts.entries()]
|
|
69
|
+
.sort((a, b) => b[1] - a[1])
|
|
70
|
+
.slice(0, 20)
|
|
71
|
+
.map(([extension, count]) => ({ extension, count }));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function riskSurfaces(files) {
|
|
75
|
+
return {
|
|
76
|
+
database: files.filter((file) => /(^|\/)(migrations?|db|database|prisma|drizzle|schema)\//i.test(file) || /\.(sql|prisma)$/i.test(file)),
|
|
77
|
+
infra: files.filter((file) => /\.(tf|ya?ml|jsonnet)$/i.test(file) && /(^|\/)(terraform|infra|k8s|kubernetes|helm|charts|\.github\/workflows)\//i.test(file)),
|
|
78
|
+
securitySensitive: files.filter((file) => /(^|\/)(auth|security|crypto|payments?|billing|permissions?|rbac|ipc|preload|main)\b/i.test(file)),
|
|
79
|
+
ci: files.filter((file) => /^\.github\/workflows\/.+\.ya?ml$/i.test(file)),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function detectElectron(root, files, packageJson) {
|
|
84
|
+
const deps = { ...(packageJson?.dependencies || {}), ...(packageJson?.devDependencies || {}) };
|
|
85
|
+
const detected =
|
|
86
|
+
Boolean(deps.electron) ||
|
|
87
|
+
files.some((file) => /(^|\/)(main|preload|electron)\.(js|jsx|ts|tsx|mjs|cjs)$/i.test(file));
|
|
88
|
+
return {
|
|
89
|
+
detected,
|
|
90
|
+
hasPreload: files.some((file) => /(^|\/)preload\.(js|ts|mjs|cjs)$/i.test(file)),
|
|
91
|
+
candidateFiles: files.filter((file) => /\.(js|jsx|ts|tsx|mjs|cjs)$/i.test(file)).slice(0, 1200),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function detectProject(projectRoot, options = {}) {
|
|
96
|
+
const root = path.resolve(projectRoot);
|
|
97
|
+
const files = listFiles(root, options);
|
|
98
|
+
const packageJson = readJson(path.join(root, "package.json"));
|
|
99
|
+
const surfaces = riskSurfaces(files);
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
root,
|
|
103
|
+
fileCount: files.length,
|
|
104
|
+
files,
|
|
105
|
+
packageJson,
|
|
106
|
+
packageManagers: packageManagers(root, files),
|
|
107
|
+
riskSurfaces: surfaces,
|
|
108
|
+
electron: detectElectron(root, files, packageJson),
|
|
109
|
+
topExtensions: topExtensions(files),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
function stableStatus(status) {
|
|
5
|
+
return status || "unknown";
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function statusKey(status) {
|
|
9
|
+
return String(stableStatus(status)).toLowerCase();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function gradeForScore(score) {
|
|
13
|
+
if (score >= 90) return "A";
|
|
14
|
+
if (score >= 75) return "B";
|
|
15
|
+
if (score >= 60) return "C";
|
|
16
|
+
if (score >= 40) return "D";
|
|
17
|
+
return "F";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function buildMaintenanceSummary({ blockers, warnings, coverageGaps, invalidExceptions }) {
|
|
21
|
+
const score = Math.max(
|
|
22
|
+
0,
|
|
23
|
+
Math.min(100, 100 - blockers.length * 25 - warnings.length * 3 - coverageGaps.length * 2 - invalidExceptions.length * 20),
|
|
24
|
+
);
|
|
25
|
+
return {
|
|
26
|
+
score,
|
|
27
|
+
grade: gradeForScore(score),
|
|
28
|
+
basis: {
|
|
29
|
+
blockers: blockers.length,
|
|
30
|
+
warnings: warnings.length,
|
|
31
|
+
coverageGaps: coverageGaps.length,
|
|
32
|
+
invalidExceptions: invalidExceptions.length,
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function buildJsonReport({
|
|
38
|
+
root,
|
|
39
|
+
mode,
|
|
40
|
+
probe,
|
|
41
|
+
checks,
|
|
42
|
+
audit = null,
|
|
43
|
+
toolVersions = {},
|
|
44
|
+
invalidExceptions = [],
|
|
45
|
+
generatedAt = new Date().toISOString(),
|
|
46
|
+
}) {
|
|
47
|
+
const blockers = checks.filter((check) => check.blocking);
|
|
48
|
+
const warnings = checks.filter((check) => !check.blocking && ["fail", "error", "missing", "skipped", "gap", "user_decision"].includes(statusKey(check.status)));
|
|
49
|
+
const coverageGaps = checks.filter((check) => check.coverageGap || ["missing", "skipped", "gap"].includes(statusKey(check.status)));
|
|
50
|
+
const exceptionUsage = checks.filter((check) => check.exception).map((check) => ({
|
|
51
|
+
check: check.name,
|
|
52
|
+
exception: check.exception,
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
schemaVersion: 1,
|
|
57
|
+
root,
|
|
58
|
+
mode,
|
|
59
|
+
passed: blockers.length === 0 && invalidExceptions.length === 0,
|
|
60
|
+
blockerCount: blockers.length + invalidExceptions.length,
|
|
61
|
+
warningCount: warnings.length,
|
|
62
|
+
coverageGapCount: coverageGaps.length,
|
|
63
|
+
maintenance: buildMaintenanceSummary({ blockers, warnings, coverageGaps, invalidExceptions }),
|
|
64
|
+
generatedAt,
|
|
65
|
+
probe,
|
|
66
|
+
audit,
|
|
67
|
+
blockers,
|
|
68
|
+
warnings,
|
|
69
|
+
coverageGaps,
|
|
70
|
+
toolVersions,
|
|
71
|
+
checks,
|
|
72
|
+
exceptions: {
|
|
73
|
+
used: exceptionUsage,
|
|
74
|
+
invalid: invalidExceptions,
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function toMarkdown(report) {
|
|
80
|
+
const lines = [];
|
|
81
|
+
lines.push(`# Local Security Gate: ${report.passed ? "PASS" : "FAIL"}`);
|
|
82
|
+
lines.push("");
|
|
83
|
+
lines.push(`Root: ${report.root}`);
|
|
84
|
+
lines.push(`Mode: strict=${Boolean(report.mode?.strict)}, release=${Boolean(report.mode?.release)}, production=${Boolean(report.mode?.production)}`);
|
|
85
|
+
lines.push(`Generated: ${report.generatedAt}`);
|
|
86
|
+
if (report.maintenance) {
|
|
87
|
+
lines.push(`Open Source Maintenance Score: ${report.maintenance.score}/100 (${report.maintenance.grade})`);
|
|
88
|
+
}
|
|
89
|
+
lines.push("");
|
|
90
|
+
|
|
91
|
+
lines.push("## Blocking Checks");
|
|
92
|
+
if (!report.blockers.length && !report.exceptions.invalid.length) lines.push("- None");
|
|
93
|
+
for (const check of report.blockers) {
|
|
94
|
+
lines.push(`- ${check.name}: ${check.status}. ${check.summary || ""}`.trim());
|
|
95
|
+
}
|
|
96
|
+
for (const exception of report.exceptions.invalid) {
|
|
97
|
+
lines.push(`- invalid exception ${exception.id || "(missing id)"}: ${exception.reason}`);
|
|
98
|
+
}
|
|
99
|
+
lines.push("");
|
|
100
|
+
|
|
101
|
+
lines.push("## Warnings");
|
|
102
|
+
if (!report.warnings.length) lines.push("- None");
|
|
103
|
+
for (const check of report.warnings) {
|
|
104
|
+
lines.push(`- ${check.name}: ${check.status}. ${check.summary || ""}`.trim());
|
|
105
|
+
}
|
|
106
|
+
lines.push("");
|
|
107
|
+
|
|
108
|
+
lines.push("## Coverage Gaps");
|
|
109
|
+
if (!report.coverageGaps.length) lines.push("- None");
|
|
110
|
+
for (const check of report.coverageGaps) {
|
|
111
|
+
lines.push(`- ${check.name}: ${check.summary || "tool unavailable"}`);
|
|
112
|
+
}
|
|
113
|
+
lines.push("");
|
|
114
|
+
|
|
115
|
+
if (report.audit) {
|
|
116
|
+
lines.push("## Production Audit");
|
|
117
|
+
lines.push(`Project Type: ${report.audit.profile?.projectType || "unknown"}`);
|
|
118
|
+
lines.push(`Database: ${Boolean(report.audit.profile?.hasDatabase)}`);
|
|
119
|
+
lines.push(`CI: ${Boolean(report.audit.profile?.hasCi)}`);
|
|
120
|
+
lines.push("");
|
|
121
|
+
|
|
122
|
+
lines.push("### Plan");
|
|
123
|
+
for (const item of report.audit.plan || []) {
|
|
124
|
+
lines.push(`- ${item.status} ${item.title}: ${item.summary}`);
|
|
125
|
+
}
|
|
126
|
+
if (!(report.audit.plan || []).length) lines.push("- None");
|
|
127
|
+
lines.push("");
|
|
128
|
+
|
|
129
|
+
lines.push("### Coverage Gaps");
|
|
130
|
+
if (!(report.audit.coverageGaps || []).length) lines.push("- None");
|
|
131
|
+
for (const gap of report.audit.coverageGaps || []) {
|
|
132
|
+
lines.push(`- ${gap.title}: ${gap.summary}${gap.recommendation ? ` Recommendation: ${gap.recommendation}` : ""}`);
|
|
133
|
+
}
|
|
134
|
+
lines.push("");
|
|
135
|
+
|
|
136
|
+
lines.push("### User Decisions");
|
|
137
|
+
if (!(report.audit.userDecisions || []).length) lines.push("- None");
|
|
138
|
+
for (const decision of report.audit.userDecisions || []) {
|
|
139
|
+
lines.push(`- ${decision.title}: ${decision.summary}${decision.recommendation ? ` Recommendation: ${decision.recommendation}` : ""}`);
|
|
140
|
+
}
|
|
141
|
+
lines.push("");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
lines.push("## Tools");
|
|
145
|
+
for (const [tool, version] of Object.entries(report.toolVersions || {})) {
|
|
146
|
+
lines.push(`- ${tool}: ${version}`);
|
|
147
|
+
}
|
|
148
|
+
if (!Object.keys(report.toolVersions || {}).length) lines.push("- None recorded");
|
|
149
|
+
lines.push("");
|
|
150
|
+
|
|
151
|
+
lines.push("## Checks Run");
|
|
152
|
+
for (const check of report.checks) {
|
|
153
|
+
lines.push(`- ${check.name}: ${check.status}${check.command ? ` (${check.command})` : ""}`);
|
|
154
|
+
}
|
|
155
|
+
lines.push("");
|
|
156
|
+
|
|
157
|
+
lines.push("## Exceptions");
|
|
158
|
+
if (!report.exceptions.used.length && !report.exceptions.invalid.length) lines.push("- None");
|
|
159
|
+
for (const item of report.exceptions.used) {
|
|
160
|
+
lines.push(`- ${item.exception.id}: applied to ${item.check}, expires ${item.exception.expires}`);
|
|
161
|
+
}
|
|
162
|
+
for (const item of report.exceptions.invalid) {
|
|
163
|
+
lines.push(`- ${item.id || "(missing id)"}: invalid, ${item.reason}`);
|
|
164
|
+
}
|
|
165
|
+
lines.push("");
|
|
166
|
+
|
|
167
|
+
lines.push("## Next Step");
|
|
168
|
+
lines.push(report.passed ? "- Gate passed. Keep this command in CI before release." : "- Fix blocking checks or add narrow, owner-approved exceptions, then rerun the gate.");
|
|
169
|
+
return lines.join("\n");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function toSarif(report) {
|
|
173
|
+
const rules = new Map();
|
|
174
|
+
const results = [];
|
|
175
|
+
|
|
176
|
+
for (const check of report.checks) {
|
|
177
|
+
if (["pass", "skipped", "missing", "n/a"].includes(statusKey(check.status))) continue;
|
|
178
|
+
const ruleId = check.name.replace(/\s+/g, "-").toLowerCase();
|
|
179
|
+
if (!rules.has(ruleId)) {
|
|
180
|
+
rules.set(ruleId, {
|
|
181
|
+
id: ruleId,
|
|
182
|
+
shortDescription: { text: check.name },
|
|
183
|
+
fullDescription: { text: check.summary || check.name },
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
results.push({
|
|
188
|
+
ruleId,
|
|
189
|
+
level: check.blocking ? "error" : "warning",
|
|
190
|
+
message: { text: check.summary || `${check.name} returned ${check.status}` },
|
|
191
|
+
locations: [
|
|
192
|
+
{
|
|
193
|
+
physicalLocation: {
|
|
194
|
+
artifactLocation: { uri: "." },
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
],
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
203
|
+
version: "2.1.0",
|
|
204
|
+
runs: [
|
|
205
|
+
{
|
|
206
|
+
tool: {
|
|
207
|
+
driver: {
|
|
208
|
+
name: "ai-project-maintainer",
|
|
209
|
+
informationUri: "https://github.com/xixifusi1213-gif/ai-project-maintainer",
|
|
210
|
+
rules: [...rules.values()],
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
results,
|
|
214
|
+
},
|
|
215
|
+
],
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function writeReportFiles(report, outputPath) {
|
|
220
|
+
const jsonPath = path.resolve(outputPath);
|
|
221
|
+
const dir = path.dirname(jsonPath);
|
|
222
|
+
const base = jsonPath.replace(/\.json$/i, "");
|
|
223
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
224
|
+
fs.writeFileSync(jsonPath, JSON.stringify(report, null, 2));
|
|
225
|
+
fs.writeFileSync(`${base}.md`, toMarkdown(report));
|
|
226
|
+
fs.writeFileSync(`${base}.sarif`, JSON.stringify(toSarif(report), null, 2));
|
|
227
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
const root = path.resolve(process.argv[2] || process.cwd());
|
|
7
|
+
const maxFiles = 8000;
|
|
8
|
+
const skipDirs = new Set([
|
|
9
|
+
".git",
|
|
10
|
+
".hg",
|
|
11
|
+
".svn",
|
|
12
|
+
"node_modules",
|
|
13
|
+
"vendor",
|
|
14
|
+
".next",
|
|
15
|
+
".nuxt",
|
|
16
|
+
"dist",
|
|
17
|
+
"build",
|
|
18
|
+
"target",
|
|
19
|
+
"coverage",
|
|
20
|
+
".venv",
|
|
21
|
+
"venv",
|
|
22
|
+
"__pycache__",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
function safeStat(filePath) {
|
|
26
|
+
try {
|
|
27
|
+
return fs.statSync(filePath);
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function rel(filePath) {
|
|
34
|
+
return path.relative(root, filePath).replaceAll(path.sep, "/") || ".";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function walk(dir, depth = 0, files = []) {
|
|
38
|
+
if (files.length >= maxFiles || depth > 8) return files;
|
|
39
|
+
let entries = [];
|
|
40
|
+
try {
|
|
41
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
42
|
+
} catch {
|
|
43
|
+
return files;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
for (const entry of entries) {
|
|
47
|
+
if (files.length >= maxFiles) break;
|
|
48
|
+
const full = path.join(dir, entry.name);
|
|
49
|
+
if (entry.isDirectory()) {
|
|
50
|
+
if (!skipDirs.has(entry.name)) walk(full, depth + 1, files);
|
|
51
|
+
} else if (entry.isFile()) {
|
|
52
|
+
files.push(rel(full));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return files;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function hasAny(files, predicates) {
|
|
59
|
+
return files.filter((file) => predicates.some((predicate) => predicate(file)));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function commandExists(command) {
|
|
63
|
+
const pathValue = process.env.PATH || "";
|
|
64
|
+
const exts = process.platform === "win32"
|
|
65
|
+
? (process.env.PATHEXT || ".EXE;.CMD;.BAT;.COM").split(";")
|
|
66
|
+
: [""];
|
|
67
|
+
for (const dir of pathValue.split(path.delimiter)) {
|
|
68
|
+
if (!dir) continue;
|
|
69
|
+
const base = path.join(dir, command);
|
|
70
|
+
for (const ext of exts) {
|
|
71
|
+
if (safeStat(base + ext)) return true;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function git(args) {
|
|
78
|
+
try {
|
|
79
|
+
return execFileSync("git", args, {
|
|
80
|
+
cwd: root,
|
|
81
|
+
encoding: "utf8",
|
|
82
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
83
|
+
}).trim();
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function countExtensions(files) {
|
|
90
|
+
const counts = {};
|
|
91
|
+
for (const file of files) {
|
|
92
|
+
const ext = path.extname(file).toLowerCase() || "[none]";
|
|
93
|
+
counts[ext] = (counts[ext] || 0) + 1;
|
|
94
|
+
}
|
|
95
|
+
return Object.fromEntries(
|
|
96
|
+
Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, 30),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const files = walk(root);
|
|
101
|
+
const lower = files.map((file) => file.toLowerCase());
|
|
102
|
+
|
|
103
|
+
const packageManagers = {
|
|
104
|
+
npm: lower.includes("package-lock.json"),
|
|
105
|
+
pnpm: lower.includes("pnpm-lock.yaml"),
|
|
106
|
+
yarn: lower.includes("yarn.lock"),
|
|
107
|
+
bun: lower.includes("bun.lockb") || lower.includes("bun.lock"),
|
|
108
|
+
pythonPip: lower.includes("requirements.txt"),
|
|
109
|
+
poetry: lower.includes("poetry.lock"),
|
|
110
|
+
pipenv: lower.includes("pipfile.lock"),
|
|
111
|
+
go: lower.includes("go.mod"),
|
|
112
|
+
rust: lower.includes("cargo.lock"),
|
|
113
|
+
maven: lower.includes("pom.xml"),
|
|
114
|
+
gradle: lower.some((file) => file.endsWith("build.gradle") || file.endsWith("build.gradle.kts")),
|
|
115
|
+
dotnet: lower.some((file) => file.endsWith(".csproj") || file.endsWith(".sln")),
|
|
116
|
+
ruby: lower.includes("gemfile.lock"),
|
|
117
|
+
php: lower.includes("composer.lock"),
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const databaseFiles = hasAny(files, [
|
|
121
|
+
(file) => /(^|\/)(migrations?|db\/migrate|schema|alembic|flyway|liquibase)(\/|$)/i.test(file),
|
|
122
|
+
(file) => /\.(sql|prisma)$/i.test(file),
|
|
123
|
+
(file) => /drizzle|knexfile|sequelize|typeorm|schema\.rb|structure\.sql/i.test(file),
|
|
124
|
+
]);
|
|
125
|
+
|
|
126
|
+
const infraFiles = hasAny(files, [
|
|
127
|
+
(file) => /(^|\/)dockerfile$/i.test(file) || /docker-compose.*\.ya?ml$/i.test(file),
|
|
128
|
+
(file) => /\.(tf|tfvars)$/i.test(file),
|
|
129
|
+
(file) => /(^|\/)(charts?|helm|k8s|kubernetes|manifests)(\/|$)/i.test(file),
|
|
130
|
+
(file) => /(^|\/)(pulumi|cloudformation|serverless)\./i.test(file),
|
|
131
|
+
(file) => /(^|\/)\.github\/workflows\/.+\.ya?ml$/i.test(file),
|
|
132
|
+
]);
|
|
133
|
+
|
|
134
|
+
const securitySensitiveFiles = hasAny(files, [
|
|
135
|
+
(file) => /(^|\/)\.env(\.|$)/i.test(file),
|
|
136
|
+
(file) => /\.(pem|key|p12|pfx|crt)$/i.test(file),
|
|
137
|
+
(file) => /(^|\/)(auth|oauth|jwt|session|middleware|passport|security|iam|policy|cors)(\/|\.|-|_)/i.test(file),
|
|
138
|
+
(file) => /(^|\/)(api|routes|controllers|handlers)(\/|$)/i.test(file),
|
|
139
|
+
]);
|
|
140
|
+
|
|
141
|
+
const ciFiles = hasAny(files, [
|
|
142
|
+
(file) => /^\.github\/workflows\/.+\.ya?ml$/i.test(file),
|
|
143
|
+
(file) => /^\.gitlab-ci\.ya?ml$/i.test(file),
|
|
144
|
+
(file) => /^circle\.yml$/i.test(file),
|
|
145
|
+
(file) => /^\.circleci\/config\.ya?ml$/i.test(file),
|
|
146
|
+
(file) => /^azure-pipelines\.ya?ml$/i.test(file),
|
|
147
|
+
(file) => /^jenkinsfile$/i.test(file),
|
|
148
|
+
]);
|
|
149
|
+
|
|
150
|
+
const tools = [
|
|
151
|
+
"git",
|
|
152
|
+
"gh",
|
|
153
|
+
"semgrep",
|
|
154
|
+
"codeql",
|
|
155
|
+
"trivy",
|
|
156
|
+
"gitleaks",
|
|
157
|
+
"checkov",
|
|
158
|
+
"osv-scanner",
|
|
159
|
+
"actionlint",
|
|
160
|
+
"zizmor",
|
|
161
|
+
"syft",
|
|
162
|
+
"grype",
|
|
163
|
+
"nuclei",
|
|
164
|
+
"zap-baseline.py",
|
|
165
|
+
"docker",
|
|
166
|
+
"kubectl",
|
|
167
|
+
"helm",
|
|
168
|
+
"k8sgpt",
|
|
169
|
+
"holmes",
|
|
170
|
+
"kubescape",
|
|
171
|
+
"conftest",
|
|
172
|
+
"opa",
|
|
173
|
+
"atlas",
|
|
174
|
+
"bytebase",
|
|
175
|
+
"squawk",
|
|
176
|
+
"pgroll",
|
|
177
|
+
"gh-ost",
|
|
178
|
+
"pt-online-schema-change",
|
|
179
|
+
"cilium",
|
|
180
|
+
"tetragon",
|
|
181
|
+
"falcoctl",
|
|
182
|
+
];
|
|
183
|
+
|
|
184
|
+
const availableTools = Object.fromEntries(
|
|
185
|
+
tools.map((tool) => [tool, commandExists(tool)]),
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
const gitStatus = availableTools.git ? git(["status", "--short"]) : null;
|
|
189
|
+
const gitBranch = availableTools.git ? git(["branch", "--show-current"]) : null;
|
|
190
|
+
const changedFilesRaw = availableTools.git ? git(["diff", "--name-only", "HEAD"]) : null;
|
|
191
|
+
|
|
192
|
+
const result = {
|
|
193
|
+
root,
|
|
194
|
+
fileCount: files.length,
|
|
195
|
+
truncated: files.length >= maxFiles,
|
|
196
|
+
git: {
|
|
197
|
+
branch: gitBranch,
|
|
198
|
+
dirty: Boolean(gitStatus),
|
|
199
|
+
statusShort: gitStatus,
|
|
200
|
+
changedFiles: changedFilesRaw ? changedFilesRaw.split(/\r?\n/).filter(Boolean) : [],
|
|
201
|
+
},
|
|
202
|
+
topExtensions: countExtensions(files),
|
|
203
|
+
packageManagers,
|
|
204
|
+
riskSurfaces: {
|
|
205
|
+
database: databaseFiles.slice(0, 80),
|
|
206
|
+
infra: infraFiles.slice(0, 80),
|
|
207
|
+
securitySensitive: securitySensitiveFiles.slice(0, 80),
|
|
208
|
+
ci: ciFiles.slice(0, 80),
|
|
209
|
+
},
|
|
210
|
+
availableTools,
|
|
211
|
+
recommendedReferences: [
|
|
212
|
+
databaseFiles.length ? "references/database.md" : null,
|
|
213
|
+
infraFiles.length || securitySensitiveFiles.length ? "references/security.md" : null,
|
|
214
|
+
ciFiles.length ? "references/ci-guardrails.md" : null,
|
|
215
|
+
].filter(Boolean),
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
console.log(JSON.stringify(result, null, 2));
|