@prodcycle/prodcycle 0.2.1 → 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/dist/cli.js CHANGED
@@ -38,12 +38,71 @@ const commander_1 = require("commander");
38
38
  const fs = __importStar(require("fs"));
39
39
  const path = __importStar(require("path"));
40
40
  const index_1 = require("./index");
41
+ const table_1 = require("./formatters/table");
42
+ const sarif_1 = require("./formatters/sarif");
43
+ const prompt_1 = require("./formatters/prompt");
44
+ const KNOWN_COMMANDS = new Set([
45
+ 'scan',
46
+ 'gate',
47
+ 'hook',
48
+ 'init',
49
+ 'help',
50
+ '--help',
51
+ '-h',
52
+ '--version',
53
+ '-V',
54
+ ]);
55
+ /**
56
+ * Back-compat shim: `prodcycle .` used to scan the current directory with no
57
+ * subcommand. Preserve that behavior by injecting `scan` when the first arg
58
+ * isn't a known subcommand or a global flag.
59
+ */
60
+ function injectScanDefault(argv) {
61
+ const args = argv.slice(2);
62
+ if (args.length === 0)
63
+ return [...argv.slice(0, 2), 'scan'];
64
+ if (KNOWN_COMMANDS.has(args[0]))
65
+ return argv;
66
+ return [...argv.slice(0, 2), 'scan', ...args];
67
+ }
68
+ function renderReport(response, format) {
69
+ switch (format) {
70
+ case 'json':
71
+ return JSON.stringify(response, null, 2);
72
+ case 'sarif':
73
+ return JSON.stringify((0, sarif_1.formatSarif)(response), null, 2);
74
+ case 'prompt':
75
+ return (0, prompt_1.formatPrompt)(response);
76
+ case 'table':
77
+ default:
78
+ return (0, table_1.formatTable)(response);
79
+ }
80
+ }
81
+ function writeOutput(text, outFile) {
82
+ if (outFile) {
83
+ fs.writeFileSync(outFile, text);
84
+ }
85
+ else {
86
+ process.stdout.write(text.endsWith('\n') ? text : text + '\n');
87
+ }
88
+ }
89
+ function parseList(val) {
90
+ if (!val)
91
+ return undefined;
92
+ return val
93
+ .split(',')
94
+ .map((s) => s.trim())
95
+ .filter(Boolean);
96
+ }
41
97
  const program = new commander_1.Command();
42
98
  program
43
99
  .name('prodcycle')
44
100
  .description('Multi-framework policy-as-code compliance scanner for infrastructure and application code.')
45
- .version('0.1.0')
46
- .argument('[repo_path]', 'Path to the repository to scan', '.')
101
+ .version('0.3.0');
102
+ // ── scan ────────────────────────────────────────────────────────────────────
103
+ program
104
+ .command('scan [repo_path]')
105
+ .description('Scan a repository for compliance violations')
47
106
  .option('--framework <ids>', 'Comma-separated framework IDs to evaluate', 'soc2')
48
107
  .option('--format <format>', 'Output format: json, sarif, table, prompt', 'table')
49
108
  .option('--severity-threshold <severity>', 'Minimum severity to include in report', 'low')
@@ -53,53 +112,107 @@ program
53
112
  .option('--output <file>', 'Write report to file')
54
113
  .option('--api-url <url>', 'Compliance API base URL (or PC_API_URL env)')
55
114
  .option('--api-key <key>', 'API key for compliance API (or PC_API_KEY env)')
56
- .option('--hook', 'Run as coding agent post-edit hook (reads stdin)')
57
- .option('--hook-file <path>', 'File path for hook mode (alternative to stdin)')
58
- .option('--hook-api', 'Run as API-based hook (calls hosted compliance API)')
59
- .option('--init', 'Set up compliance hooks for coding agents')
60
- .option('--agent <agents>', 'Comma-separated agents to configure')
61
115
  .action(async (repoPath, opts) => {
62
116
  try {
63
- if (opts.hook || opts.hookApi) {
64
- // Implement hook logic here
65
- console.log('Hook mode executed.');
66
- process.exit(0);
67
- }
68
- if (opts.init) {
69
- // Implement init logic here
70
- console.log('Init mode executed.');
71
- process.exit(0);
72
- }
73
- const frameworks = opts.framework.split(',').map((s) => s.trim());
74
- const failOn = opts.failOn.split(',').map((s) => s.trim());
75
- const include = opts.include ? opts.include.split(',') : undefined;
76
- const exclude = opts.exclude ? opts.exclude.split(',') : undefined;
77
- console.log(`Scanning ${path.resolve(repoPath)} for ${frameworks.join(', ')}...`);
117
+ const target = repoPath ?? '.';
118
+ const frameworks = parseList(opts.framework) ?? ['soc2'];
119
+ const failOn = parseList(opts.failOn) ?? ['critical', 'high'];
120
+ const format = (opts.format ?? 'table');
121
+ console.error(`Scanning ${path.resolve(target)} for ${frameworks.join(', ')}...`);
78
122
  const response = await (0, index_1.scan)({
79
- repoPath,
123
+ repoPath: target,
80
124
  frameworks,
81
125
  options: {
82
126
  severityThreshold: opts.severityThreshold,
83
- failOn,
84
- include,
85
- exclude,
127
+ failOn: failOn,
128
+ include: parseList(opts.include),
129
+ exclude: parseList(opts.exclude),
86
130
  apiUrl: opts.apiUrl,
87
131
  apiKey: opts.apiKey,
88
- }
132
+ },
89
133
  });
90
- if (opts.format === 'json') {
91
- const output = JSON.stringify(response, null, 2);
92
- if (opts.output) {
93
- fs.writeFileSync(opts.output, output);
94
- }
95
- else {
96
- console.log(output);
97
- }
134
+ writeOutput(renderReport(response, format), opts.output);
135
+ process.exit(response.exitCode);
136
+ }
137
+ catch (error) {
138
+ console.error(`\u2717 Error: ${error.message}`);
139
+ process.exit(2);
140
+ }
141
+ });
142
+ // ── gate ────────────────────────────────────────────────────────────────────
143
+ program
144
+ .command('gate')
145
+ .description('Evaluate a JSON payload of files from stdin (low-latency hook endpoint)')
146
+ .option('--framework <ids>', 'Comma-separated framework IDs to evaluate', 'soc2')
147
+ .option('--format <format>', 'Output format: json, sarif, table, prompt', 'prompt')
148
+ .option('--output <file>', 'Write report to file')
149
+ .option('--api-url <url>', 'Compliance API base URL (or PC_API_URL env)')
150
+ .option('--api-key <key>', 'API key for compliance API (or PC_API_KEY env)')
151
+ .action(async (opts) => {
152
+ try {
153
+ const frameworks = parseList(opts.framework) ?? ['soc2'];
154
+ const format = (opts.format ?? 'prompt');
155
+ const stdin = await readStdin();
156
+ if (!stdin.trim()) {
157
+ console.error('gate: no input on stdin. Expected JSON payload: {"files": {...}}');
158
+ process.exit(2);
159
+ }
160
+ let payload;
161
+ try {
162
+ payload = JSON.parse(stdin);
98
163
  }
99
- else {
100
- console.log(`Passed: ${response.passed}`);
101
- console.log(`Findings: ${response.findings.length}`);
164
+ catch (e) {
165
+ console.error(`gate: invalid JSON on stdin: ${e.message}`);
166
+ process.exit(2);
167
+ return;
168
+ }
169
+ if (!payload.files || typeof payload.files !== 'object') {
170
+ console.error('gate: payload must include a "files" object of {path: content}');
171
+ process.exit(2);
172
+ return;
173
+ }
174
+ const response = await (0, index_1.gate)({
175
+ files: payload.files,
176
+ frameworks,
177
+ apiUrl: opts.apiUrl,
178
+ apiKey: opts.apiKey,
179
+ });
180
+ writeOutput(renderReport(response, format), opts.output);
181
+ process.exit(response.exitCode);
182
+ }
183
+ catch (error) {
184
+ console.error(`\u2717 Error: ${error.message}`);
185
+ process.exit(2);
186
+ }
187
+ });
188
+ // ── hook ────────────────────────────────────────────────────────────────────
189
+ program
190
+ .command('hook')
191
+ .description('Run as coding-agent post-edit hook (reads stdin or --file)')
192
+ .option('--framework <ids>', 'Comma-separated framework IDs to evaluate', 'soc2')
193
+ .option('--format <format>', 'Output format: json, sarif, table, prompt', 'prompt')
194
+ .option('--file <path>', 'Scan this file from disk (alternative to reading content from stdin)')
195
+ .option('--fail-on <levels>', 'Severities that cause non-zero exit', 'critical,high')
196
+ .option('--output <file>', 'Write report to file')
197
+ .option('--api-url <url>', 'Compliance API base URL (or PC_API_URL env)')
198
+ .option('--api-key <key>', 'API key for compliance API (or PC_API_KEY env)')
199
+ .action(async (opts) => {
200
+ try {
201
+ const frameworks = parseList(opts.framework) ?? ['soc2'];
202
+ const format = (opts.format ?? 'prompt');
203
+ const files = await collectHookFiles(opts.file);
204
+ if (!files || Object.keys(files).length === 0) {
205
+ // No files to check — exit clean so the agent proceeds.
206
+ process.exit(0);
207
+ return;
102
208
  }
209
+ const response = await (0, index_1.gate)({
210
+ files,
211
+ frameworks,
212
+ apiUrl: opts.apiUrl,
213
+ apiKey: opts.apiKey,
214
+ });
215
+ writeOutput(renderReport(response, format), opts.output);
103
216
  process.exit(response.exitCode);
104
217
  }
105
218
  catch (error) {
@@ -107,4 +220,186 @@ program
107
220
  process.exit(2);
108
221
  }
109
222
  });
110
- program.parse();
223
+ /**
224
+ * Resolve the files to scan for a `hook` invocation. Supports:
225
+ * - `--file <path>` — read that file from disk
226
+ * - stdin: `{"files": {path: content}}` (same as gate)
227
+ * - stdin: `{"file_path": "...", "content": "..."}` (single file)
228
+ * - stdin: Claude Code PostToolUse shape —
229
+ * `{"tool_input": {"file_path": "...", "content"|"new_string": "..."}}`
230
+ * When only `file_path` is given and we can read the file, we do.
231
+ */
232
+ async function collectHookFiles(filePath) {
233
+ if (filePath) {
234
+ const absolute = path.resolve(filePath);
235
+ if (!fs.existsSync(absolute)) {
236
+ console.error(`hook: --file path does not exist: ${absolute}`);
237
+ process.exit(2);
238
+ }
239
+ const content = fs.readFileSync(absolute, 'utf8');
240
+ return { [filePath]: content };
241
+ }
242
+ const stdin = await readStdin();
243
+ if (!stdin.trim()) {
244
+ console.error('hook: no input. Provide --file <path> or JSON on stdin (see `prodcycle hook --help`).');
245
+ process.exit(2);
246
+ }
247
+ let payload;
248
+ try {
249
+ payload = JSON.parse(stdin);
250
+ }
251
+ catch (e) {
252
+ console.error(`hook: invalid JSON on stdin: ${e.message}`);
253
+ process.exit(2);
254
+ }
255
+ // Shape 1: {"files": {path: content}} — gate-compatible
256
+ if (payload && typeof payload.files === 'object' && payload.files !== null) {
257
+ return payload.files;
258
+ }
259
+ // Shape 2: top-level single file. Shape 3: Claude Code tool_input nesting.
260
+ const candidate = payload?.tool_input ?? payload;
261
+ const hookFilePath = candidate?.file_path ?? candidate?.path;
262
+ const hookContent = candidate?.content ?? candidate?.new_string;
263
+ if (hookFilePath && typeof hookContent === 'string') {
264
+ return { [hookFilePath]: hookContent };
265
+ }
266
+ if (hookFilePath && fs.existsSync(hookFilePath)) {
267
+ // Only a path was given — read from disk so post-edit hooks still work
268
+ // when the agent doesn't ship the content inline.
269
+ const content = fs.readFileSync(hookFilePath, 'utf8');
270
+ return { [hookFilePath]: content };
271
+ }
272
+ console.error('hook: stdin payload not recognized. Expected one of:\n' +
273
+ ' {"files": {"path": "content"}}\n' +
274
+ ' {"file_path": "...", "content": "..."}\n' +
275
+ ' {"tool_input": {"file_path": "...", "content": "..."}}');
276
+ process.exit(2);
277
+ return null; // unreachable
278
+ }
279
+ // ── init ────────────────────────────────────────────────────────────────────
280
+ program
281
+ .command('init')
282
+ .description('Configure compliance hooks for coding agents')
283
+ .option('--agent <agents>', 'Comma-separated agents to configure (claude, cursor). Default: auto-detect.')
284
+ .option('--force', 'Overwrite existing compliance hook entries')
285
+ .option('--dir <path>', 'Project directory to configure', '.')
286
+ .action((opts) => {
287
+ try {
288
+ const dir = path.resolve(opts.dir ?? '.');
289
+ const agents = resolveAgents(opts.agent, dir);
290
+ if (agents.length === 0) {
291
+ console.error('init: no agents selected and none auto-detected. ' +
292
+ 'Use --agent claude (or cursor) to configure explicitly.');
293
+ process.exit(2);
294
+ }
295
+ let anyFailed = false;
296
+ for (const agent of agents) {
297
+ const result = configureAgent(agent, dir, !!opts.force);
298
+ process.stdout.write(result.message + '\n');
299
+ if (result.status === 'failed')
300
+ anyFailed = true;
301
+ }
302
+ process.exit(anyFailed ? 1 : 0);
303
+ }
304
+ catch (error) {
305
+ console.error(`\u2717 Error: ${error.message}`);
306
+ process.exit(2);
307
+ }
308
+ });
309
+ function resolveAgents(userChoice, dir) {
310
+ if (userChoice) {
311
+ const list = parseList(userChoice) ?? [];
312
+ const valid = [];
313
+ for (const name of list) {
314
+ if (name === 'claude' || name === 'cursor')
315
+ valid.push(name);
316
+ else
317
+ console.error(`init: unknown agent "${name}" — ignoring`);
318
+ }
319
+ return valid;
320
+ }
321
+ // Auto-detect
322
+ const detected = [];
323
+ if (fs.existsSync(path.join(dir, '.claude')))
324
+ detected.push('claude');
325
+ if (fs.existsSync(path.join(dir, '.cursor')))
326
+ detected.push('cursor');
327
+ return detected;
328
+ }
329
+ function configureAgent(agent, dir, force) {
330
+ switch (agent) {
331
+ case 'claude':
332
+ return configureClaudeCode(dir, force);
333
+ case 'cursor':
334
+ return {
335
+ status: 'failed',
336
+ message: '[cursor] skipped — Cursor does not currently expose a post-edit hook mechanism.\n' +
337
+ ' Add a `.cursor/rules` entry pointing reviewers at `prodcycle scan .` until hook support lands.',
338
+ };
339
+ }
340
+ }
341
+ const CLAUDE_MATCHER = 'Write|Edit|MultiEdit';
342
+ const CLAUDE_COMMAND = 'prodcycle hook';
343
+ function configureClaudeCode(dir, force) {
344
+ const claudeDir = path.join(dir, '.claude');
345
+ const settingsPath = path.join(claudeDir, 'settings.json');
346
+ let settings = {};
347
+ if (fs.existsSync(settingsPath)) {
348
+ try {
349
+ settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
350
+ if (typeof settings !== 'object' || settings === null || Array.isArray(settings)) {
351
+ return {
352
+ status: 'failed',
353
+ message: `[claude] ${settingsPath} is not a JSON object — refusing to overwrite. Fix the file manually.`,
354
+ };
355
+ }
356
+ }
357
+ catch (e) {
358
+ return {
359
+ status: 'failed',
360
+ message: `[claude] could not parse ${settingsPath}: ${e.message}. Fix the file manually.`,
361
+ };
362
+ }
363
+ }
364
+ const hooks = (settings.hooks ??= {});
365
+ const postToolUse = (hooks.PostToolUse ??= []);
366
+ // Look for an existing prodcycle entry
367
+ const existing = postToolUse.find((b) => b.hooks?.some((h) => h.type === 'command' && h.command.trim().startsWith('prodcycle hook')));
368
+ if (existing && !force) {
369
+ return {
370
+ status: 'already',
371
+ message: `[claude] PostToolUse hook for prodcycle already present in ${settingsPath}. Use --force to rewrite.`,
372
+ };
373
+ }
374
+ if (existing && force) {
375
+ // Replace in place — preserve the matcher, rewrite the command to the canonical form
376
+ existing.matcher = CLAUDE_MATCHER;
377
+ existing.hooks = [{ type: 'command', command: CLAUDE_COMMAND }];
378
+ }
379
+ else {
380
+ postToolUse.push({
381
+ matcher: CLAUDE_MATCHER,
382
+ hooks: [{ type: 'command', command: CLAUDE_COMMAND }],
383
+ });
384
+ }
385
+ if (!fs.existsSync(claudeDir))
386
+ fs.mkdirSync(claudeDir, { recursive: true });
387
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
388
+ return {
389
+ status: 'installed',
390
+ message: `[claude] wrote PostToolUse hook to ${settingsPath}. Requires PC_API_KEY in the environment when Claude Code runs.`,
391
+ };
392
+ }
393
+ function readStdin() {
394
+ return new Promise((resolve, reject) => {
395
+ if (process.stdin.isTTY) {
396
+ resolve('');
397
+ return;
398
+ }
399
+ const chunks = [];
400
+ process.stdin.on('data', (c) => chunks.push(c));
401
+ process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
402
+ process.stdin.on('error', reject);
403
+ });
404
+ }
405
+ program.parse(injectScanDefault(process.argv));
@@ -1 +1,5 @@
1
- export declare function formatPrompt(report: any): string;
1
+ /**
2
+ * Render a coding-agent-oriented prompt describing findings. If the server
3
+ * returned a pre-built `prompt` field (hook endpoint), prefer that.
4
+ */
5
+ export declare function formatPrompt(report: unknown): string;
@@ -1,8 +1,37 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.formatPrompt = formatPrompt;
4
+ /**
5
+ * Render a coding-agent-oriented prompt describing findings. If the server
6
+ * returned a pre-built `prompt` field (hook endpoint), prefer that.
7
+ */
4
8
  function formatPrompt(report) {
5
- if (!report)
9
+ const r = (report ?? {});
10
+ if (typeof r.prompt === 'string' && r.prompt.trim())
11
+ return r.prompt;
12
+ const findings = r.findings ?? [];
13
+ if (findings.length === 0)
14
+ return 'No compliance violations detected.';
15
+ const lines = [];
16
+ lines.push(`Compliance scan found ${findings.length} violation(s) that need to be addressed:`);
17
+ lines.push('');
18
+ for (const f of findings) {
19
+ const sev = (f.severity ?? 'unknown').toUpperCase();
20
+ const rule = f.rule_id ?? f.ruleId ?? 'unknown';
21
+ const loc = locOf(f);
22
+ const title = f.title ?? f.message ?? '';
23
+ lines.push(`- [${sev}] ${rule}${loc ? ` (${loc})` : ''}: ${title}`);
24
+ if (f.description && f.description !== title) {
25
+ lines.push(` ${f.description}`);
26
+ }
27
+ }
28
+ lines.push('');
29
+ lines.push('Please update the code to resolve these issues before continuing.');
30
+ return lines.join('\n');
31
+ }
32
+ function locOf(f) {
33
+ const file = f.file ?? f.path;
34
+ if (!file)
6
35
  return '';
7
- return 'Please fix the compliance issues found in this repository.';
36
+ return f.line ? `${file}:${f.line}` : file;
8
37
  }
@@ -1 +1 @@
1
- export declare function formatSarif(report: any): any;
1
+ export declare function formatSarif(report: unknown): object;
@@ -1,9 +1,74 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.formatSarif = formatSarif;
4
+ /**
5
+ * SARIF 2.1.0 level mapping for compliance severities.
6
+ * Spec: https://docs.oasis-open.org/sarif/sarif/v2.1.0/os/sarif-v2.1.0-os.html
7
+ */
8
+ function sarifLevel(sev) {
9
+ const s = (sev ?? '').toLowerCase();
10
+ if (s === 'critical' || s === 'high')
11
+ return 'error';
12
+ if (s === 'medium')
13
+ return 'warning';
14
+ return 'note';
15
+ }
4
16
  function formatSarif(report) {
17
+ const r = (report ?? {});
18
+ const findings = r.findings ?? [];
19
+ const rulesById = new Map();
20
+ const results = findings.map((f) => {
21
+ const ruleId = f.rule_id ?? f.ruleId ?? 'unknown';
22
+ if (!rulesById.has(ruleId)) {
23
+ rulesById.set(ruleId, {
24
+ id: ruleId,
25
+ name: ruleId,
26
+ shortDescription: { text: f.title ?? ruleId },
27
+ ...(f.description ? { fullDescription: { text: f.description } } : {}),
28
+ });
29
+ }
30
+ const file = f.file ?? f.path ?? '';
31
+ const startLine = f.line;
32
+ const endLine = f.end_line ?? f.endLine;
33
+ return {
34
+ ruleId,
35
+ level: sarifLevel(f.severity),
36
+ message: { text: f.message ?? f.title ?? ruleId },
37
+ ...(file
38
+ ? {
39
+ locations: [
40
+ {
41
+ physicalLocation: {
42
+ artifactLocation: { uri: file },
43
+ ...(startLine
44
+ ? {
45
+ region: {
46
+ startLine,
47
+ ...(endLine ? { endLine } : {}),
48
+ },
49
+ }
50
+ : {}),
51
+ },
52
+ },
53
+ ],
54
+ }
55
+ : {}),
56
+ };
57
+ });
5
58
  return {
6
59
  version: '2.1.0',
7
- runs: [{ tool: { driver: { name: 'ProdCycle Compliance Scanner' } }, results: [] }]
60
+ $schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json',
61
+ runs: [
62
+ {
63
+ tool: {
64
+ driver: {
65
+ name: 'ProdCycle Compliance Scanner',
66
+ informationUri: 'https://docs.prodcycle.com',
67
+ rules: [...rulesById.values()],
68
+ },
69
+ },
70
+ results,
71
+ },
72
+ ],
8
73
  };
9
74
  }
@@ -1 +1 @@
1
- export declare function formatTable(report: any): string;
1
+ export declare function formatTable(report: unknown): string;
@@ -1,9 +1,43 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.formatTable = formatTable;
4
+ const SEVERITY_ORDER = ['critical', 'high', 'medium', 'low', 'info'];
5
+ function sevRank(s) {
6
+ const i = SEVERITY_ORDER.indexOf((s ?? '').toLowerCase());
7
+ return i === -1 ? SEVERITY_ORDER.length : i;
8
+ }
4
9
  function formatTable(report) {
5
- // Simplistic table formatter
6
- if (!report)
7
- return 'No report data';
8
- return `Scan Results: ${report.summary?.passed || 0} passed, ${report.summary?.failed || 0} failed.`;
10
+ const r = (report ?? {});
11
+ const findings = r.findings ?? [];
12
+ if (findings.length === 0) {
13
+ return r.passed === false ? 'Scan failed but no findings returned.' : '\u2713 No compliance violations found.';
14
+ }
15
+ const sorted = [...findings].sort((a, b) => sevRank(a.severity) - sevRank(b.severity));
16
+ const counts = new Map();
17
+ for (const f of findings) {
18
+ const sev = (f.severity ?? 'unknown').toLowerCase();
19
+ counts.set(sev, (counts.get(sev) ?? 0) + 1);
20
+ }
21
+ const lines = [];
22
+ lines.push(`Findings: ${findings.length}`);
23
+ const summaryParts = SEVERITY_ORDER.filter((s) => counts.has(s)).map((s) => `${counts.get(s)} ${s}`);
24
+ if (summaryParts.length > 0)
25
+ lines.push(` ${summaryParts.join(', ')}`);
26
+ lines.push('');
27
+ for (const f of sorted) {
28
+ const sev = (f.severity ?? 'unknown').toUpperCase().padEnd(8);
29
+ const rule = f.rule_id ?? f.ruleId ?? '';
30
+ const loc = locOf(f);
31
+ const title = f.title ?? f.message ?? f.description ?? '';
32
+ lines.push(` [${sev}] ${rule} ${title}`);
33
+ if (loc)
34
+ lines.push(` ${loc}`);
35
+ }
36
+ return lines.join('\n');
37
+ }
38
+ function locOf(f) {
39
+ const file = f.file ?? f.path;
40
+ if (!file)
41
+ return '';
42
+ return f.line ? `${file}:${f.line}` : file;
9
43
  }
package/dist/index.d.ts CHANGED
@@ -33,18 +33,3 @@ export declare function gate(options: GateOptions): Promise<{
33
33
  prompt: any;
34
34
  summary: any;
35
35
  }>;
36
- /**
37
- * Run local hook
38
- */
39
- export declare function runHook(params: {
40
- frameworks?: string[];
41
- filePath?: string;
42
- }): Promise<number>;
43
- /**
44
- * Run API hook
45
- */
46
- export declare function runHookApi(params: {
47
- apiUrl?: string;
48
- apiKey?: string;
49
- frameworks?: string[];
50
- }): Promise<number>;
package/dist/index.js CHANGED
@@ -16,8 +16,6 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
17
  exports.scan = scan;
18
18
  exports.gate = gate;
19
- exports.runHook = runHook;
20
- exports.runHookApi = runHookApi;
21
19
  const api_client_1 = require("./api-client");
22
20
  const fs_1 = require("./utils/fs");
23
21
  __exportStar(require("./api-client"), exports);
@@ -64,19 +62,3 @@ async function gate(options) {
64
62
  summary: response.summary
65
63
  };
66
64
  }
67
- /**
68
- * Run local hook
69
- */
70
- async function runHook(params) {
71
- // Logic to read stdin or specific file and call gate
72
- const { frameworks = ['soc2'], filePath } = params;
73
- // Implementation details...
74
- return 0;
75
- }
76
- /**
77
- * Run API hook
78
- */
79
- async function runHookApi(params) {
80
- // Implementation details...
81
- return 0;
82
- }
package/dist/utils/fs.js CHANGED
@@ -36,66 +36,116 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.collectFiles = collectFiles;
37
37
  const fs = __importStar(require("fs"));
38
38
  const path = __importStar(require("path"));
39
- const glob_1 = require("glob");
39
+ const minimatch_1 = require("minimatch");
40
40
  const MAX_FILE_SIZE = 256 * 1024; // 256 KB
41
41
  const MAX_TOTAL_FILES = 10_000;
42
- async function collectFiles(baseDir, includePatterns, excludePatterns) {
43
- // Simple implementation using glob
44
- const patterns = includePatterns && includePatterns.length > 0 ? includePatterns : ['**/*'];
45
- const ignore = [
46
- 'node_modules/**',
47
- '.git/**',
48
- '.terraform/**',
49
- 'dist/**',
50
- 'build/**',
51
- '**/__pycache__/**',
52
- '.next/**',
53
- '.nuxt/**',
54
- 'vendor/**',
55
- 'coverage/**',
56
- '.venv/**',
57
- 'venv/**',
58
- '.tox/**',
59
- 'target/**',
60
- '*.lock',
61
- 'package-lock.json',
62
- '*.min.js',
63
- '*.min.css',
64
- '*.map',
65
- '*.bundle.js',
66
- '*.tfstate',
67
- '*.tfstate.backup',
68
- ];
69
- if (excludePatterns && excludePatterns.length > 0) {
70
- ignore.push(...excludePatterns);
42
+ /**
43
+ * Directories skipped unconditionally. Kept in parity with
44
+ * `packages/compliance-code-scanner/src/ignore-utils.ts`.
45
+ */
46
+ const SKIP_DIRS = new Set([
47
+ 'node_modules',
48
+ 'vendor',
49
+ '__pycache__',
50
+ '.terraform',
51
+ '.git',
52
+ 'dist',
53
+ '.venv',
54
+ 'venv',
55
+ 'build',
56
+ 'out',
57
+ '.next',
58
+ '.nuxt',
59
+ '.output',
60
+ '.cache',
61
+ '.parcel-cache',
62
+ 'coverage',
63
+ '.nyc_output',
64
+ '.turbo',
65
+ 'target',
66
+ '.gradle',
67
+ '.mvn',
68
+ '.idea',
69
+ '.vscode',
70
+ '.eggs',
71
+ '.tox',
72
+ '.mypy_cache',
73
+ '.ruff_cache',
74
+ '.pytest_cache',
75
+ 'bower_components',
76
+ '.svn',
77
+ '.hg',
78
+ '__snapshots__',
79
+ ]);
80
+ const SKIP_DIR_SUFFIXES = ['.egg-info'];
81
+ const SKIP_FILE_EXTENSIONS = ['.lock', '.min.js', '.min.css', '.map', '.bundle.js', '.tfstate', '.tfstate.backup'];
82
+ const SKIP_FILE_NAMES = new Set(['package-lock.json']);
83
+ /**
84
+ * Load .gitignore patterns from the repo root.
85
+ *
86
+ * Negation patterns (`!foo`) are dropped — minimatch does not interpret them
87
+ * as gitignore would, and passing them through causes directory-wide blindness
88
+ * (see server-side fix in ignore-utils.ts).
89
+ */
90
+ function loadGitignore(repoPath) {
91
+ try {
92
+ const gitignorePath = path.join(repoPath, '.gitignore');
93
+ if (!fs.existsSync(gitignorePath))
94
+ return [];
95
+ const content = fs.readFileSync(gitignorePath, 'utf-8');
96
+ return content
97
+ .split('\n')
98
+ .map((line) => line.trim())
99
+ .filter((line) => line && !line.startsWith('#') && !line.startsWith('!'))
100
+ .map((line) => (line.endsWith('/') ? line.slice(0, -1) : line));
71
101
  }
72
- const matches = await (0, glob_1.glob)(patterns, {
73
- cwd: baseDir,
74
- ignore,
75
- nodir: true,
76
- });
77
- const files = {};
78
- let count = 0;
79
- for (const match of matches) {
80
- if (count >= MAX_TOTAL_FILES) {
81
- console.warn(`Reached max file limit (${MAX_TOTAL_FILES}). Some files were skipped.`);
82
- break;
83
- }
84
- const fullPath = path.join(baseDir, match);
85
- const stats = fs.statSync(fullPath);
86
- // Skip large files
87
- if (stats.size > MAX_FILE_SIZE) {
88
- continue;
102
+ catch {
103
+ return [];
104
+ }
105
+ }
106
+ function matchesAny(filePath, patterns) {
107
+ return patterns.some((p) => (0, minimatch_1.minimatch)(filePath, p));
108
+ }
109
+ /**
110
+ * Decide whether a directory or file entry should be excluded from collection.
111
+ * Mirrors server `shouldIgnore` so scanner results stay consistent between
112
+ * client-collected (CLI) and server-collected paths.
113
+ */
114
+ function shouldIgnore(name, relPath, ignores, userExcludes) {
115
+ if (SKIP_DIRS.has(name) ||
116
+ SKIP_DIR_SUFFIXES.some((s) => name.endsWith(s)) ||
117
+ (name.startsWith('.') &&
118
+ !name.startsWith('.env') &&
119
+ !name.startsWith('.github') &&
120
+ !name.startsWith('.gitlab'))) {
121
+ return true;
122
+ }
123
+ if (userExcludes && userExcludes.length > 0) {
124
+ for (const pattern of userExcludes) {
125
+ if (name === pattern ||
126
+ name + '/' === pattern ||
127
+ relPath === pattern ||
128
+ relPath + '/' === pattern) {
129
+ return true;
130
+ }
89
131
  }
90
- // Basic heuristic to skip binary files
91
- const buffer = fs.readFileSync(fullPath);
92
- if (isBinary(buffer)) {
93
- continue;
132
+ if (matchesAny(relPath, userExcludes))
133
+ return true;
134
+ }
135
+ // .env* files are always scanned, even if listed in .gitignore (common case)
136
+ if (name.startsWith('.env') || name.endsWith('.env'))
137
+ return false;
138
+ for (const pattern of ignores) {
139
+ if (name === pattern ||
140
+ name + '/' === pattern ||
141
+ relPath === pattern ||
142
+ relPath + '/' === pattern) {
143
+ return true;
94
144
  }
95
- files[match] = buffer.toString('utf8');
96
- count++;
97
145
  }
98
- return files;
146
+ if (matchesAny(relPath, ignores))
147
+ return true;
148
+ return false;
99
149
  }
100
150
  function isBinary(buffer) {
101
151
  for (let i = 0; i < Math.min(buffer.length, 1024); i++) {
@@ -104,3 +154,74 @@ function isBinary(buffer) {
104
154
  }
105
155
  return false;
106
156
  }
157
+ function shouldSkipFileByName(name) {
158
+ if (SKIP_FILE_NAMES.has(name))
159
+ return true;
160
+ return SKIP_FILE_EXTENSIONS.some((ext) => name.endsWith(ext));
161
+ }
162
+ async function collectFiles(baseDir, includePatterns, excludePatterns) {
163
+ const repoRoot = path.resolve(baseDir);
164
+ const ignores = loadGitignore(repoRoot);
165
+ const files = {};
166
+ const state = { count: 0, limitReached: false };
167
+ walk(repoRoot, repoRoot, ignores, includePatterns, excludePatterns, files, state);
168
+ return files;
169
+ }
170
+ function walk(dir, repoRoot, ignores, includePatterns, userExcludes, files, state) {
171
+ if (state.limitReached)
172
+ return;
173
+ let entries;
174
+ try {
175
+ entries = fs.readdirSync(dir, { withFileTypes: true });
176
+ }
177
+ catch {
178
+ return;
179
+ }
180
+ for (const entry of entries) {
181
+ if (state.limitReached)
182
+ return;
183
+ const name = entry.name;
184
+ const fullPath = path.join(dir, name);
185
+ const relPath = path.relative(repoRoot, fullPath);
186
+ if (entry.isDirectory()) {
187
+ if (shouldIgnore(name, relPath, ignores, userExcludes))
188
+ continue;
189
+ walk(fullPath, repoRoot, ignores, includePatterns, userExcludes, files, state);
190
+ continue;
191
+ }
192
+ if (!entry.isFile())
193
+ continue;
194
+ if (shouldIgnore(name, relPath, ignores, userExcludes))
195
+ continue;
196
+ if (shouldSkipFileByName(name))
197
+ continue;
198
+ if (includePatterns && includePatterns.length > 0 && !matchesAny(relPath, includePatterns)) {
199
+ continue;
200
+ }
201
+ if (state.count >= MAX_TOTAL_FILES) {
202
+ console.warn(`Reached max file limit (${MAX_TOTAL_FILES}). Some files were skipped.`);
203
+ state.limitReached = true;
204
+ return;
205
+ }
206
+ let stats;
207
+ try {
208
+ stats = fs.statSync(fullPath);
209
+ }
210
+ catch {
211
+ continue;
212
+ }
213
+ if (stats.size > MAX_FILE_SIZE)
214
+ continue;
215
+ let buffer;
216
+ try {
217
+ buffer = fs.readFileSync(fullPath);
218
+ }
219
+ catch {
220
+ continue;
221
+ }
222
+ if (isBinary(buffer))
223
+ continue;
224
+ files[relPath] = buffer.toString('utf8');
225
+ state.count++;
226
+ }
227
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prodcycle/prodcycle",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Multi-framework policy-as-code compliance scanner for infrastructure and application code.",
5
5
  "homepage": "https://docs.prodcycle.com",
6
6
  "repository": {
@@ -34,8 +34,7 @@
34
34
  "license": "SEE LICENSE IN LICENSE",
35
35
  "dependencies": {
36
36
  "commander": "^12.0.0",
37
- "glob": "^10.3.10",
38
- "ignore": "^5.3.1"
37
+ "minimatch": "^9.0.3"
39
38
  },
40
39
  "devDependencies": {
41
40
  "@types/node": "^22.0.0",