codeproof 1.0.0 → 1.0.1
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/bin/codeproof.js +7 -1
- package/commands/init.js +23 -1
- package/commands/reportDashboard.js +13 -34
- package/commands/run.js +24 -31
- package/commands/whoami.js +19 -0
- package/core/identity.js +78 -0
- package/package.json +5 -2
- package/reporting/reportBuilder.js +6 -3
- package/reporting/reportReader.js +49 -0
- package/reporting/reportWriter.js +53 -10
- package/utils/fileScanner.js +40 -0
- package/utils/gitIgnore.js +55 -0
package/bin/codeproof.js
CHANGED
|
@@ -3,13 +3,14 @@ import { runInit } from "../commands/init.js";
|
|
|
3
3
|
import { runCli } from "../commands/run.js";
|
|
4
4
|
import { runReportDashboard } from "../commands/reportDashboard.js";
|
|
5
5
|
import { runMoveSecret } from "../commands/moveSecret.js";
|
|
6
|
+
import { runWhoAmI } from "../commands/whoami.js";
|
|
6
7
|
import { logError, logInfo } from "../utils/logger.js";
|
|
7
8
|
|
|
8
9
|
const [, , command, ...args] = process.argv;
|
|
9
10
|
|
|
10
11
|
async function main() {
|
|
11
12
|
if (!command || command === "-h" || command === "--help") {
|
|
12
|
-
logInfo("Usage: codeproof <command>\n\nCommands:\n init Initialize CodeProof in a Git repository\n run Run CodeProof checks (stub)\n report@dashboard Send latest report and show dashboard link\n move-secret Move high-confidence secrets to .env");
|
|
13
|
+
logInfo("Usage: codeproof <command>\n\nCommands:\n init Initialize CodeProof in a Git repository\n run Run CodeProof checks (stub)\n report@dashboard Send latest report and show dashboard link\n move-secret Move high-confidence secrets to .env\n whoami Show the local CodeProof client ID");
|
|
13
14
|
process.exit(0);
|
|
14
15
|
}
|
|
15
16
|
|
|
@@ -33,6 +34,11 @@ async function main() {
|
|
|
33
34
|
return;
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
if (command === "whoami") {
|
|
38
|
+
await runWhoAmI();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
36
42
|
logError(`Unknown command: ${command}`);
|
|
37
43
|
process.exit(1);
|
|
38
44
|
}
|
package/commands/init.js
CHANGED
|
@@ -5,11 +5,15 @@ import { logInfo, logSuccess, logWarn } from "../utils/logger.js";
|
|
|
5
5
|
import { detectProjectType } from "../utils/projectType.js";
|
|
6
6
|
import { installPreCommitHook } from "../hooks/preCommit.js";
|
|
7
7
|
import { showWelcomeScreen } from "../ui/welcomeScreen.js";
|
|
8
|
+
import { getClientId } from "../core/identity.js";
|
|
9
|
+
import { randomUUID } from "crypto";
|
|
8
10
|
|
|
9
11
|
|
|
10
12
|
|
|
11
13
|
export async function runInit({ cwd }) {
|
|
12
14
|
logInfo("Initializing CodeProof...");
|
|
15
|
+
|
|
16
|
+
getClientId();
|
|
13
17
|
|
|
14
18
|
ensureGitRepo(cwd);
|
|
15
19
|
logSuccess("Git repository detected.");
|
|
@@ -23,9 +27,27 @@ export async function runInit({ cwd }) {
|
|
|
23
27
|
const configPath = path.join(gitRoot, "codeproof.config.json");
|
|
24
28
|
// Avoid overwriting user configuration to keep init idempotent.
|
|
25
29
|
if (fs.existsSync(configPath)) {
|
|
26
|
-
|
|
30
|
+
let updated = false;
|
|
31
|
+
try {
|
|
32
|
+
const raw = fs.readFileSync(configPath, "utf8");
|
|
33
|
+
const existing = JSON.parse(raw);
|
|
34
|
+
if (!existing.projectId) {
|
|
35
|
+
existing.projectId = randomUUID();
|
|
36
|
+
updated = true;
|
|
37
|
+
fs.writeFileSync(configPath, JSON.stringify(existing, null, 2) + "\n", "utf8");
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
logWarn("Config already exists but could not be updated.");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (updated) {
|
|
44
|
+
logSuccess("Added projectId to codeproof.config.json");
|
|
45
|
+
} else {
|
|
46
|
+
logWarn("Config already exists. Skipping creation.");
|
|
47
|
+
}
|
|
27
48
|
} else {
|
|
28
49
|
const config = {
|
|
50
|
+
projectId: randomUUID(),
|
|
29
51
|
projectType,
|
|
30
52
|
scanMode: "staged",
|
|
31
53
|
features: {
|
|
@@ -5,6 +5,7 @@ import { logError, logInfo, logWarn } from "../utils/logger.js";
|
|
|
5
5
|
import { sendReportToServer } from "../utils/apiClient.js";
|
|
6
6
|
import { resolveFeatureFlags, isVerbose } from "../core/featureFlags.js";
|
|
7
7
|
import { reportFeatureDisabled, withFailOpenIntegration } from "../core/safetyGuards.js";
|
|
8
|
+
import { readLatestReport } from "../reporting/reportReader.js";
|
|
8
9
|
|
|
9
10
|
function readConfig(configPath) {
|
|
10
11
|
if (!fs.existsSync(configPath)) {
|
|
@@ -21,24 +22,6 @@ function readConfig(configPath) {
|
|
|
21
22
|
}
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
function readLatestReport(reportPath) {
|
|
25
|
-
if (!fs.existsSync(reportPath)) {
|
|
26
|
-
return null;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const content = fs.readFileSync(reportPath, "utf8");
|
|
30
|
-
const lines = content.split(/\r?\n/).filter(Boolean);
|
|
31
|
-
if (lines.length === 0) {
|
|
32
|
-
return null;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
try {
|
|
36
|
-
return JSON.parse(lines[lines.length - 1]);
|
|
37
|
-
} catch {
|
|
38
|
-
return null;
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
25
|
export async function runReportDashboard({ cwd }) {
|
|
43
26
|
// Boundary: CLI orchestration only. Avoid importing this module in lower layers.
|
|
44
27
|
ensureGitRepo(cwd);
|
|
@@ -49,30 +32,26 @@ export async function runReportDashboard({ cwd }) {
|
|
|
49
32
|
const verbose = isVerbose(config);
|
|
50
33
|
|
|
51
34
|
if (features.reporting) {
|
|
52
|
-
const
|
|
53
|
-
const latestReport =
|
|
35
|
+
const latestEntry = readLatestReport(gitRoot);
|
|
36
|
+
const latestReport = latestEntry?.report || null;
|
|
54
37
|
|
|
55
38
|
if (!latestReport) {
|
|
56
|
-
logWarn("No reports found. Run
|
|
39
|
+
logWarn("No reports found. Run `codeproof run` first.");
|
|
57
40
|
} else {
|
|
58
41
|
const integration = config?.integration || {};
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
enabled: true,
|
|
65
|
-
endpointUrl: integration.endpointUrl
|
|
66
|
-
});
|
|
42
|
+
// Integrations are fail-open: never throw on network errors.
|
|
43
|
+
withFailOpenIntegration(() => {
|
|
44
|
+
sendReportToServer(latestReport, {
|
|
45
|
+
enabled: true,
|
|
46
|
+
endpointUrl: integration.endpointUrl
|
|
67
47
|
});
|
|
68
|
-
}
|
|
69
|
-
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
if (latestReport?.projectId) {
|
|
51
|
+
logInfo(`View dashboard: https://dashboard.codeproof.dev/project/${latestReport.projectId}`);
|
|
70
52
|
}
|
|
71
53
|
}
|
|
72
54
|
} else {
|
|
73
55
|
reportFeatureDisabled("Reporting", verbose, logInfo);
|
|
74
56
|
}
|
|
75
|
-
|
|
76
|
-
const projectId = encodeURIComponent(path.basename(gitRoot) || "project");
|
|
77
|
-
logInfo(`Dashboard: https://dashboard.codeproof.dev/project/${projectId}`);
|
|
78
57
|
}
|
package/commands/run.js
CHANGED
|
@@ -2,15 +2,16 @@ import fs from "fs";
|
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { ensureGitRepo, getGitRoot, getStagedFiles } from "../utils/git.js";
|
|
4
4
|
import { logError, logInfo, logSuccess, logWarn } from "../utils/logger.js";
|
|
5
|
-
import {
|
|
5
|
+
import { buildScanTargets } from "../utils/fileScanner.js";
|
|
6
6
|
import { runRuleEngine } from "../engine/ruleEngine.js";
|
|
7
7
|
import { analyze } from "../engine/aiAnalyzer.js";
|
|
8
8
|
import { mergeDecisions } from "../engine/decisionMerger.js";
|
|
9
9
|
import { buildAiInputs, buildProjectContext } from "../engine/contextBuilder.js";
|
|
10
10
|
import { buildReport } from "../reporting/reportBuilder.js";
|
|
11
|
-
import {
|
|
11
|
+
import { writeReport } from "../reporting/reportWriter.js";
|
|
12
12
|
import { sendReportToServer } from "../utils/apiClient.js";
|
|
13
13
|
import { resolveFeatureFlags, isVerbose } from "../core/featureFlags.js";
|
|
14
|
+
import { getClientId } from "../core/identity.js";
|
|
14
15
|
import {
|
|
15
16
|
reportFeatureDisabled,
|
|
16
17
|
warnExperimentalOnce,
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
withFailOpenIntegration,
|
|
19
20
|
withFailOpenReporting
|
|
20
21
|
} from "../core/safetyGuards.js";
|
|
22
|
+
import { randomUUID } from "crypto";
|
|
21
23
|
|
|
22
24
|
function readConfig(configPath) {
|
|
23
25
|
if (!fs.existsSync(configPath)) {
|
|
@@ -34,22 +36,6 @@ function readConfig(configPath) {
|
|
|
34
36
|
}
|
|
35
37
|
}
|
|
36
38
|
|
|
37
|
-
function normalizeScopeFiles(files, gitRoot) {
|
|
38
|
-
return files
|
|
39
|
-
.map((filePath) => (path.isAbsolute(filePath) ? filePath : path.join(gitRoot, filePath)))
|
|
40
|
-
.filter((filePath) => {
|
|
41
|
-
try {
|
|
42
|
-
return fs.statSync(filePath).isFile();
|
|
43
|
-
} catch {
|
|
44
|
-
return false;
|
|
45
|
-
}
|
|
46
|
-
});
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function filterBinaryFiles(files) {
|
|
50
|
-
return files.filter((filePath) => !isBinaryFile(filePath));
|
|
51
|
-
}
|
|
52
|
-
|
|
53
39
|
export async function runCli({ cwd }) {
|
|
54
40
|
// Boundary: CLI orchestration only. Avoid importing this module in lower layers.
|
|
55
41
|
logInfo("CodeProof run started.");
|
|
@@ -71,27 +57,30 @@ export async function runCli({ cwd }) {
|
|
|
71
57
|
|
|
72
58
|
if (scanMode === "staged") {
|
|
73
59
|
logInfo("Scan mode: staged");
|
|
74
|
-
|
|
75
|
-
|
|
60
|
+
targets = buildScanTargets({
|
|
61
|
+
gitRoot,
|
|
62
|
+
scanMode,
|
|
63
|
+
stagedFiles: getStagedFiles(gitRoot)
|
|
64
|
+
});
|
|
76
65
|
} else if (scanMode === "full") {
|
|
77
66
|
logInfo("Scan mode: full");
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
67
|
+
targets = buildScanTargets({
|
|
68
|
+
gitRoot,
|
|
69
|
+
scanMode,
|
|
70
|
+
stagedFiles: []
|
|
71
|
+
});
|
|
81
72
|
} else {
|
|
82
73
|
logError("Invalid scanMode. Expected 'staged' or 'full'.");
|
|
83
74
|
process.exit(1);
|
|
84
75
|
}
|
|
85
76
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if (filtered.length === 0) {
|
|
77
|
+
if (targets.length === 0) {
|
|
89
78
|
logWarn("No relevant files found. Exiting.");
|
|
90
79
|
// Exit code 0 allows the Git commit to continue.
|
|
91
80
|
process.exit(0);
|
|
92
81
|
}
|
|
93
82
|
|
|
94
|
-
const { findings, escalations } = runRuleEngine({ files:
|
|
83
|
+
const { findings, escalations } = runRuleEngine({ files: targets });
|
|
95
84
|
const projectContext = buildProjectContext({ gitRoot, config });
|
|
96
85
|
let aiInputs = [];
|
|
97
86
|
if (features.aiEscalation) {
|
|
@@ -116,18 +105,22 @@ export async function runCli({ cwd }) {
|
|
|
116
105
|
if (features.reporting) {
|
|
117
106
|
withFailOpenReporting(() => {
|
|
118
107
|
const timestamp = new Date().toISOString();
|
|
119
|
-
const
|
|
108
|
+
const reportId = randomUUID();
|
|
109
|
+
const projectId = config.projectId || "";
|
|
110
|
+
const clientId = getClientId();
|
|
120
111
|
const report = buildReport({
|
|
121
112
|
projectRoot: gitRoot,
|
|
113
|
+
projectId,
|
|
114
|
+
clientId,
|
|
115
|
+
reportId,
|
|
122
116
|
scanMode,
|
|
123
|
-
filesScannedCount:
|
|
117
|
+
filesScannedCount: targets.length,
|
|
124
118
|
baselineFindings: [...findings, ...escalations],
|
|
125
119
|
aiReviewed,
|
|
126
|
-
runId,
|
|
127
120
|
timestamp
|
|
128
121
|
});
|
|
129
122
|
// Reporting is fail-open: never block commits if logging fails.
|
|
130
|
-
|
|
123
|
+
writeReport({ projectRoot: gitRoot, report });
|
|
131
124
|
|
|
132
125
|
const integration = config?.integration || {};
|
|
133
126
|
const integrationEnabled = features.integration && Boolean(integration.enabled);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { readClientId, getGlobalConfigPath } from "../core/identity.js";
|
|
2
|
+
import { logError, logInfo } from "../utils/logger.js";
|
|
3
|
+
|
|
4
|
+
export async function runWhoAmI() {
|
|
5
|
+
const clientId = readClientId();
|
|
6
|
+
|
|
7
|
+
if (!clientId) {
|
|
8
|
+
logError("CodeProof not initialized. Run codeproof init first.");
|
|
9
|
+
process.exit(1);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
logInfo("CodeProof Client ID:");
|
|
13
|
+
logInfo(clientId);
|
|
14
|
+
logInfo("");
|
|
15
|
+
logInfo("Use this ID to log in at:");
|
|
16
|
+
logInfo("https://your-dashboard-url.com/login");
|
|
17
|
+
logInfo("");
|
|
18
|
+
logInfo(`Config: ${getGlobalConfigPath()}`);
|
|
19
|
+
}
|
package/core/identity.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { randomUUID } from "crypto";
|
|
5
|
+
|
|
6
|
+
const CONFIG_DIR_NAME = ".codeproof";
|
|
7
|
+
const CONFIG_FILE_NAME = "config.json";
|
|
8
|
+
|
|
9
|
+
function getConfigDir() {
|
|
10
|
+
return path.join(os.homedir(), CONFIG_DIR_NAME);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function getConfigPath() {
|
|
14
|
+
return path.join(getConfigDir(), CONFIG_FILE_NAME);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function ensureConfigDir() {
|
|
18
|
+
const dir = getConfigDir();
|
|
19
|
+
if (!fs.existsSync(dir)) {
|
|
20
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
fs.chmodSync(dir, 0o700);
|
|
24
|
+
} catch {
|
|
25
|
+
// Best-effort on platforms that ignore chmod.
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function writeConfig(config) {
|
|
30
|
+
const filePath = getConfigPath();
|
|
31
|
+
const payload = JSON.stringify(config, null, 2) + "\n";
|
|
32
|
+
fs.writeFileSync(filePath, payload, { encoding: "utf8", mode: 0o600 });
|
|
33
|
+
try {
|
|
34
|
+
fs.chmodSync(filePath, 0o600);
|
|
35
|
+
} catch {
|
|
36
|
+
// Best-effort on platforms that ignore chmod.
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function readConfig() {
|
|
41
|
+
const filePath = getConfigPath();
|
|
42
|
+
if (!fs.existsSync(filePath)) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
48
|
+
return JSON.parse(raw);
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function ensureClientId() {
|
|
55
|
+
ensureConfigDir();
|
|
56
|
+
|
|
57
|
+
const existing = readConfig();
|
|
58
|
+
if (existing?.clientId) {
|
|
59
|
+
return existing.clientId;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const clientId = randomUUID();
|
|
63
|
+
writeConfig({ clientId });
|
|
64
|
+
return clientId;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function getClientId() {
|
|
68
|
+
return ensureClientId();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function readClientId() {
|
|
72
|
+
const existing = readConfig();
|
|
73
|
+
return existing?.clientId ?? null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function getGlobalConfigPath() {
|
|
77
|
+
return getConfigPath();
|
|
78
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codeproof",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "CodeProof CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,5 +9,8 @@
|
|
|
9
9
|
"engines": {
|
|
10
10
|
"node": ">=18"
|
|
11
11
|
},
|
|
12
|
-
"license": "MIT"
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"uuid": "^13.0.0"
|
|
15
|
+
}
|
|
13
16
|
}
|
|
@@ -45,11 +45,13 @@ function normalizeSeverity(value) {
|
|
|
45
45
|
|
|
46
46
|
export function buildReport({
|
|
47
47
|
projectRoot,
|
|
48
|
+
projectId,
|
|
49
|
+
clientId,
|
|
50
|
+
reportId,
|
|
48
51
|
scanMode,
|
|
49
52
|
filesScannedCount,
|
|
50
53
|
baselineFindings,
|
|
51
54
|
aiReviewed,
|
|
52
|
-
runId,
|
|
53
55
|
timestamp
|
|
54
56
|
}) {
|
|
55
57
|
const aiById = new Map(aiReviewed.map((entry) => [entry.finding.findingId, entry.decision]));
|
|
@@ -84,9 +86,10 @@ export function buildReport({
|
|
|
84
86
|
: "allowed";
|
|
85
87
|
|
|
86
88
|
return {
|
|
87
|
-
|
|
89
|
+
reportId,
|
|
88
90
|
timestamp,
|
|
89
|
-
|
|
91
|
+
projectId,
|
|
92
|
+
clientId,
|
|
90
93
|
scanMode,
|
|
91
94
|
summary: {
|
|
92
95
|
totalFilesScanned: filesScannedCount,
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
const REPORT_DIR_NAME = "codeproof-reports";
|
|
5
|
+
const REPORT_PATTERN = /^report-(\d+)\.json$/;
|
|
6
|
+
|
|
7
|
+
function getReportDir(projectRoot) {
|
|
8
|
+
return path.join(projectRoot, REPORT_DIR_NAME);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function getReportNumbers(files) {
|
|
12
|
+
return files
|
|
13
|
+
.map((file) => {
|
|
14
|
+
const match = REPORT_PATTERN.exec(file);
|
|
15
|
+
return match ? Number.parseInt(match[1], 10) : null;
|
|
16
|
+
})
|
|
17
|
+
.filter((value) => Number.isInteger(value));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function readLatestReport(projectRoot) {
|
|
21
|
+
const reportDir = getReportDir(projectRoot);
|
|
22
|
+
if (!fs.existsSync(reportDir)) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let files = [];
|
|
27
|
+
try {
|
|
28
|
+
files = fs.readdirSync(reportDir);
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const numbers = getReportNumbers(files).sort((a, b) => b - a);
|
|
34
|
+
if (numbers.length === 0) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
for (const number of numbers) {
|
|
39
|
+
const reportPath = path.join(reportDir, `report-${number}.json`);
|
|
40
|
+
try {
|
|
41
|
+
const raw = fs.readFileSync(reportPath, "utf8");
|
|
42
|
+
return { report: JSON.parse(raw), reportPath };
|
|
43
|
+
} catch {
|
|
44
|
+
// Skip unreadable or invalid JSON reports instead of crashing.
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
@@ -1,19 +1,62 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
|
-
import os from "os";
|
|
3
2
|
import path from "path";
|
|
4
3
|
|
|
4
|
+
const REPORT_DIR_NAME = "codeproof-reports";
|
|
5
|
+
const REPORT_PREFIX = "report-";
|
|
6
|
+
const REPORT_SUFFIX = ".json";
|
|
7
|
+
const REPORT_PATTERN = /^report-(\d+)\.json$/;
|
|
8
|
+
|
|
5
9
|
// Boundary: reporting storage only. Must not import rule logic, AI logic, or integrations.
|
|
6
10
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
11
|
+
function getReportDir(projectRoot) {
|
|
12
|
+
return path.join(projectRoot, REPORT_DIR_NAME);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function ensureReportDir(reportDir) {
|
|
16
|
+
if (!fs.existsSync(reportDir)) {
|
|
17
|
+
fs.mkdirSync(reportDir, { recursive: true });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
10
20
|
|
|
11
|
-
|
|
12
|
-
|
|
21
|
+
function getNextReportNumber(reportDir) {
|
|
22
|
+
let files = [];
|
|
13
23
|
try {
|
|
14
|
-
fs.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
24
|
+
files = fs.readdirSync(reportDir);
|
|
25
|
+
} catch {
|
|
26
|
+
return 1;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const numbers = files
|
|
30
|
+
.map((file) => {
|
|
31
|
+
const match = REPORT_PATTERN.exec(file);
|
|
32
|
+
return match ? Number.parseInt(match[1], 10) : null;
|
|
33
|
+
})
|
|
34
|
+
.filter((value) => Number.isInteger(value));
|
|
35
|
+
|
|
36
|
+
if (numbers.length === 0) {
|
|
37
|
+
return 1;
|
|
18
38
|
}
|
|
39
|
+
|
|
40
|
+
return Math.max(...numbers) + 1;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function writeReport({ projectRoot, report }) {
|
|
44
|
+
const reportDir = getReportDir(projectRoot);
|
|
45
|
+
ensureReportDir(reportDir);
|
|
46
|
+
|
|
47
|
+
// Per-run JSON keeps every audit entry immutable and easy to archive.
|
|
48
|
+
let reportNumber = getNextReportNumber(reportDir);
|
|
49
|
+
let reportPath = path.join(reportDir, `${REPORT_PREFIX}${reportNumber}${REPORT_SUFFIX}`);
|
|
50
|
+
while (fs.existsSync(reportPath)) {
|
|
51
|
+
reportNumber += 1;
|
|
52
|
+
reportPath = path.join(reportDir, `${REPORT_PREFIX}${reportNumber}${REPORT_SUFFIX}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Use numeric sequencing over timestamps to avoid collisions in fast CI runs.
|
|
56
|
+
const tempPath = path.join(reportDir, `.tmp-${process.pid}-${Date.now()}.json`);
|
|
57
|
+
const payload = JSON.stringify(report, null, 2) + "\n";
|
|
58
|
+
fs.writeFileSync(tempPath, payload, "utf8");
|
|
59
|
+
fs.renameSync(tempPath, reportPath);
|
|
60
|
+
|
|
61
|
+
return reportPath;
|
|
19
62
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { filterIgnoredFiles } from "./gitIgnore.js";
|
|
4
|
+
import { getDefaultExcludes, isBinaryFile, listFilesRecursive } from "./files.js";
|
|
5
|
+
|
|
6
|
+
function normalizeScopeFiles(files, gitRoot) {
|
|
7
|
+
return files
|
|
8
|
+
.map((filePath) => (path.isAbsolute(filePath) ? filePath : path.join(gitRoot, filePath)))
|
|
9
|
+
.filter((filePath) => {
|
|
10
|
+
try {
|
|
11
|
+
return fs.statSync(filePath).isFile();
|
|
12
|
+
} catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isExcludedByDefault(filePath, gitRoot, excludes) {
|
|
19
|
+
const relative = path.relative(gitRoot, filePath);
|
|
20
|
+
const segments = relative.split(path.sep);
|
|
21
|
+
return segments.some((segment) => excludes.has(segment));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function buildScanTargets({ gitRoot, scanMode, stagedFiles }) {
|
|
25
|
+
const excludes = getDefaultExcludes();
|
|
26
|
+
|
|
27
|
+
let scopeFiles = [];
|
|
28
|
+
if (scanMode === "staged") {
|
|
29
|
+
const normalized = normalizeScopeFiles(stagedFiles, gitRoot);
|
|
30
|
+
scopeFiles = normalized.filter((filePath) => !isExcludedByDefault(filePath, gitRoot, excludes));
|
|
31
|
+
} else {
|
|
32
|
+
scopeFiles = normalizeScopeFiles(listFilesRecursive(gitRoot, excludes), gitRoot);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Respect .gitignore via `git check-ignore` so scans only touch tracked sources.
|
|
36
|
+
const notIgnored = filterIgnoredFiles({ gitRoot, filePaths: scopeFiles });
|
|
37
|
+
|
|
38
|
+
// Exclude binary files to avoid false positives and wasted I/O.
|
|
39
|
+
return notIgnored.filter((filePath) => !isBinaryFile(filePath));
|
|
40
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { spawnSync } from "child_process";
|
|
3
|
+
|
|
4
|
+
function normalizePathForGit(filePath, gitRoot) {
|
|
5
|
+
const relative = path.isAbsolute(filePath)
|
|
6
|
+
? path.relative(gitRoot, filePath)
|
|
7
|
+
: filePath;
|
|
8
|
+
|
|
9
|
+
if (!relative || relative.startsWith("..")) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return relative.replace(/\\/g, "/");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function filterIgnoredFiles({ gitRoot, filePaths }) {
|
|
17
|
+
if (!Array.isArray(filePaths) || filePaths.length === 0) {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const normalized = new Map();
|
|
22
|
+
for (const filePath of filePaths) {
|
|
23
|
+
const relative = normalizePathForGit(filePath, gitRoot);
|
|
24
|
+
if (relative) {
|
|
25
|
+
normalized.set(relative, filePath);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (normalized.size === 0) {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Use `git check-ignore` to respect .gitignore rules quickly and accurately.
|
|
34
|
+
const input = `${Array.from(normalized.keys()).join("\u0000")}\u0000`;
|
|
35
|
+
const result = spawnSync("git", ["check-ignore", "--stdin", "-z"], {
|
|
36
|
+
cwd: gitRoot,
|
|
37
|
+
input,
|
|
38
|
+
encoding: "utf8"
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
if (result.error || (result.status && result.status > 1)) {
|
|
42
|
+
return Array.from(normalized.values());
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const ignored = new Set(
|
|
46
|
+
String(result.stdout)
|
|
47
|
+
.split("\u0000")
|
|
48
|
+
.map((entry) => entry.trim())
|
|
49
|
+
.filter(Boolean)
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
return Array.from(normalized.entries())
|
|
53
|
+
.filter(([relative]) => !ignored.has(relative))
|
|
54
|
+
.map(([, original]) => original);
|
|
55
|
+
}
|