@prodcycle/prodcycle 0.2.2 → 0.4.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.4.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,72 @@ 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;
102
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);
103
181
  process.exit(response.exitCode);
104
182
  }
105
183
  catch (error) {
@@ -107,4 +185,367 @@ program
107
185
  process.exit(2);
108
186
  }
109
187
  });
110
- program.parse();
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;
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);
216
+ process.exit(response.exitCode);
217
+ }
218
+ catch (error) {
219
+ console.error(`\u2717 Error: ${error.message}`);
220
+ process.exit(2);
221
+ }
222
+ });
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, codex, opencode, github-copilot, gemini-cli). Use "all" to configure every agent. 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 <name> to configure explicitly (claude, cursor, codex, ' +
293
+ 'opencode, github-copilot, gemini-cli, or "all").');
294
+ process.exit(2);
295
+ }
296
+ let anyFailed = false;
297
+ const writtenPaths = new Set();
298
+ for (const agent of agents) {
299
+ const result = configureAgent(agent, dir, !!opts.force, writtenPaths);
300
+ process.stdout.write(result.message + '\n');
301
+ if (result.status === 'failed')
302
+ anyFailed = true;
303
+ }
304
+ process.exit(anyFailed ? 1 : 0);
305
+ }
306
+ catch (error) {
307
+ console.error(`\u2717 Error: ${error.message}`);
308
+ process.exit(2);
309
+ }
310
+ });
311
+ const ALL_AGENTS = [
312
+ 'claude',
313
+ 'cursor',
314
+ 'codex',
315
+ 'opencode',
316
+ 'github-copilot',
317
+ 'gemini-cli',
318
+ ];
319
+ function isAgentName(name) {
320
+ return ALL_AGENTS.includes(name);
321
+ }
322
+ function resolveAgents(userChoice, dir) {
323
+ if (userChoice) {
324
+ const list = parseList(userChoice) ?? [];
325
+ if (list.length === 1 && list[0] === 'all')
326
+ return ALL_AGENTS.slice();
327
+ const valid = [];
328
+ for (const name of list) {
329
+ if (isAgentName(name))
330
+ valid.push(name);
331
+ else
332
+ console.error(`init: unknown agent "${name}" — ignoring`);
333
+ }
334
+ return valid;
335
+ }
336
+ // Auto-detect: look for config dirs/files that indicate the agent is already in use.
337
+ const detected = [];
338
+ if (fs.existsSync(path.join(dir, '.claude')))
339
+ detected.push('claude');
340
+ if (fs.existsSync(path.join(dir, '.cursor')))
341
+ detected.push('cursor');
342
+ if (fs.existsSync(path.join(dir, '.codex')))
343
+ detected.push('codex');
344
+ if (fs.existsSync(path.join(dir, '.opencode')))
345
+ detected.push('opencode');
346
+ if (fs.existsSync(path.join(dir, '.github', 'copilot-instructions.md'))) {
347
+ detected.push('github-copilot');
348
+ }
349
+ if (fs.existsSync(path.join(dir, 'GEMINI.md')) ||
350
+ fs.existsSync(path.join(dir, '.gemini'))) {
351
+ detected.push('gemini-cli');
352
+ }
353
+ return detected;
354
+ }
355
+ function configureAgent(agent, dir, force, writtenPaths) {
356
+ switch (agent) {
357
+ case 'claude':
358
+ return configureClaudeCode(dir, force);
359
+ case 'cursor':
360
+ return configureCursor(dir, force);
361
+ case 'codex':
362
+ return configureInstructionFile(agent, dir, 'AGENTS.md', force, writtenPaths);
363
+ case 'opencode':
364
+ return configureInstructionFile(agent, dir, 'AGENTS.md', force, writtenPaths);
365
+ case 'github-copilot':
366
+ return configureInstructionFile(agent, dir, path.join('.github', 'copilot-instructions.md'), force, writtenPaths);
367
+ case 'gemini-cli':
368
+ return configureInstructionFile(agent, dir, 'GEMINI.md', force, writtenPaths);
369
+ }
370
+ }
371
+ const CLAUDE_MATCHER = 'Write|Edit|MultiEdit';
372
+ const CLAUDE_COMMAND = 'prodcycle hook';
373
+ function configureClaudeCode(dir, force) {
374
+ const claudeDir = path.join(dir, '.claude');
375
+ const settingsPath = path.join(claudeDir, 'settings.json');
376
+ let settings = {};
377
+ if (fs.existsSync(settingsPath)) {
378
+ try {
379
+ settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
380
+ if (typeof settings !== 'object' || settings === null || Array.isArray(settings)) {
381
+ return {
382
+ status: 'failed',
383
+ message: `[claude] ${settingsPath} is not a JSON object — refusing to overwrite. Fix the file manually.`,
384
+ };
385
+ }
386
+ }
387
+ catch (e) {
388
+ return {
389
+ status: 'failed',
390
+ message: `[claude] could not parse ${settingsPath}: ${e.message}. Fix the file manually.`,
391
+ };
392
+ }
393
+ }
394
+ const hooks = (settings.hooks ??= {});
395
+ const postToolUse = (hooks.PostToolUse ??= []);
396
+ // Look for an existing prodcycle entry
397
+ const existing = postToolUse.find((b) => b.hooks?.some((h) => h.type === 'command' && h.command.trim().startsWith('prodcycle hook')));
398
+ if (existing && !force) {
399
+ return {
400
+ status: 'already',
401
+ message: `[claude] PostToolUse hook for prodcycle already present in ${settingsPath}. Use --force to rewrite.`,
402
+ };
403
+ }
404
+ if (existing && force) {
405
+ // Replace in place — preserve the matcher, rewrite the command to the canonical form
406
+ existing.matcher = CLAUDE_MATCHER;
407
+ existing.hooks = [{ type: 'command', command: CLAUDE_COMMAND }];
408
+ }
409
+ else {
410
+ postToolUse.push({
411
+ matcher: CLAUDE_MATCHER,
412
+ hooks: [{ type: 'command', command: CLAUDE_COMMAND }],
413
+ });
414
+ }
415
+ if (!fs.existsSync(claudeDir))
416
+ fs.mkdirSync(claudeDir, { recursive: true });
417
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
418
+ return {
419
+ status: 'installed',
420
+ message: `[claude] wrote PostToolUse hook to ${settingsPath}. Requires PC_API_KEY in the environment when Claude Code runs.`,
421
+ };
422
+ }
423
+ const CURSOR_COMMAND = 'prodcycle hook';
424
+ function configureCursor(dir, force) {
425
+ const cursorDir = path.join(dir, '.cursor');
426
+ const hooksPath = path.join(cursorDir, 'hooks.json');
427
+ let config = { version: 1 };
428
+ if (fs.existsSync(hooksPath)) {
429
+ try {
430
+ const parsed = JSON.parse(fs.readFileSync(hooksPath, 'utf8'));
431
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
432
+ return {
433
+ status: 'failed',
434
+ message: `[cursor] ${hooksPath} is not a JSON object — refusing to overwrite. Fix the file manually.`,
435
+ };
436
+ }
437
+ config = parsed;
438
+ }
439
+ catch (e) {
440
+ return {
441
+ status: 'failed',
442
+ message: `[cursor] could not parse ${hooksPath}: ${e.message}. Fix the file manually.`,
443
+ };
444
+ }
445
+ }
446
+ if (typeof config.version !== 'number')
447
+ config.version = 1;
448
+ const hooks = (config.hooks ??= {});
449
+ const afterFileEdit = (hooks.afterFileEdit ??= []);
450
+ const existing = afterFileEdit.find((h) => typeof h.command === 'string' && h.command.trim().startsWith('prodcycle hook'));
451
+ if (existing && !force) {
452
+ return {
453
+ status: 'already',
454
+ message: `[cursor] afterFileEdit hook for prodcycle already present in ${hooksPath}. Use --force to rewrite.`,
455
+ };
456
+ }
457
+ if (existing && force) {
458
+ existing.command = CURSOR_COMMAND;
459
+ }
460
+ else {
461
+ afterFileEdit.push({ command: CURSOR_COMMAND });
462
+ }
463
+ if (!fs.existsSync(cursorDir))
464
+ fs.mkdirSync(cursorDir, { recursive: true });
465
+ fs.writeFileSync(hooksPath, JSON.stringify(config, null, 2) + '\n');
466
+ return {
467
+ status: 'installed',
468
+ message: `[cursor] wrote afterFileEdit hook to ${hooksPath}. Requires PC_API_KEY in the environment when Cursor runs.`,
469
+ };
470
+ }
471
+ // ── Instruction-file agents (codex, opencode, github-copilot, gemini-cli) ───
472
+ const INSTRUCTION_BEGIN = '<!-- prodcycle:begin -->';
473
+ const INSTRUCTION_END = '<!-- prodcycle:end -->';
474
+ function buildInstructionBlock() {
475
+ return [
476
+ INSTRUCTION_BEGIN,
477
+ '## ProdCycle Compliance',
478
+ '',
479
+ 'This project enforces SOC 2, HIPAA, and NIST CSF policies on infrastructure',
480
+ 'and application code. After creating or modifying any file, run the compliance',
481
+ 'scanner and fix every violation before continuing:',
482
+ '',
483
+ '```bash',
484
+ 'npx prodcycle scan . --format prompt',
485
+ '```',
486
+ '',
487
+ 'Each finding includes a severity, rule ID, message, and remediation guidance.',
488
+ 'Apply the fixes, then re-run the scan until every check passes.',
489
+ INSTRUCTION_END,
490
+ '',
491
+ ].join('\n');
492
+ }
493
+ function configureInstructionFile(agent, dir, relPath, force, writtenPaths) {
494
+ const fullPath = path.join(dir, relPath);
495
+ // Multiple agents may share the same target file (codex + opencode → AGENTS.md).
496
+ // Skip the second agent silently if we already wrote to the same file in this run.
497
+ if (writtenPaths.has(fullPath)) {
498
+ return {
499
+ status: 'already',
500
+ message: `[${agent}] ${relPath} already written in this run (shared with another agent).`,
501
+ };
502
+ }
503
+ let existing = '';
504
+ if (fs.existsSync(fullPath)) {
505
+ existing = fs.readFileSync(fullPath, 'utf8');
506
+ }
507
+ const block = buildInstructionBlock();
508
+ const hasBlock = existing.includes(INSTRUCTION_BEGIN) && existing.includes(INSTRUCTION_END);
509
+ if (hasBlock && !force) {
510
+ return {
511
+ status: 'already',
512
+ message: `[${agent}] prodcycle instruction block already present in ${fullPath}. Use --force to rewrite.`,
513
+ };
514
+ }
515
+ let next;
516
+ if (hasBlock) {
517
+ const pattern = new RegExp(`${escapeRegExp(INSTRUCTION_BEGIN)}[\\s\\S]*?${escapeRegExp(INSTRUCTION_END)}\\n?`);
518
+ next = existing.replace(pattern, block);
519
+ }
520
+ else if (existing.trim().length === 0) {
521
+ next = block;
522
+ }
523
+ else {
524
+ next = existing.replace(/\n*$/, '\n\n') + block;
525
+ }
526
+ const parent = path.dirname(fullPath);
527
+ if (!fs.existsSync(parent))
528
+ fs.mkdirSync(parent, { recursive: true });
529
+ fs.writeFileSync(fullPath, next);
530
+ writtenPaths.add(fullPath);
531
+ return {
532
+ status: 'installed',
533
+ message: `[${agent}] wrote compliance instructions to ${fullPath}.`,
534
+ };
535
+ }
536
+ function escapeRegExp(s) {
537
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
538
+ }
539
+ function readStdin() {
540
+ return new Promise((resolve, reject) => {
541
+ if (process.stdin.isTTY) {
542
+ resolve('');
543
+ return;
544
+ }
545
+ const chunks = [];
546
+ process.stdin.on('data', (c) => chunks.push(c));
547
+ process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
548
+ process.stdin.on('error', reject);
549
+ });
550
+ }
551
+ 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.2",
3
+ "version": "0.4.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",