@prodcycle/prodcycle 0.6.7 → 0.6.9

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.
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ComplianceApiClient = exports.ApiError = void 0;
4
4
  exports.chunkFiles = chunkFiles;
5
+ const config_1 = require("./utils/config");
5
6
  /**
6
7
  * Error thrown for any non-2xx response. Carries the parsed body + status so
7
8
  * callers can branch on `details.suggestedEndpoint` (413 → chunked-session
@@ -98,8 +99,12 @@ class ComplianceApiClient {
98
99
  apiUrl;
99
100
  apiKey;
100
101
  constructor(apiUrl, apiKey) {
101
- this.apiUrl = apiUrl || process.env.PC_API_URL || DEFAULT_API_URL;
102
- this.apiKey = apiKey || process.env.PC_API_KEY || '';
102
+ // Resolution order (for both): explicit arg → env var → user config
103
+ // file default. See `utils/config.ts`. Read the config file once and
104
+ // resolve both values from it rather than re-reading per value.
105
+ const config = (0, config_1.readConfig)();
106
+ this.apiUrl = (0, config_1.resolveApiUrl)(apiUrl, config) || DEFAULT_API_URL;
107
+ this.apiKey = (0, config_1.resolveApiKey)(apiKey, config);
103
108
  if (!this.apiKey &&
104
109
  process.env.NODE_ENV !== 'test' &&
105
110
  !process.env.PC_SUPPRESS_WARNINGS) {
package/dist/cli.d.ts CHANGED
@@ -77,3 +77,15 @@ export declare function formatClaudeHookOutput(response: {
77
77
  prompt?: string;
78
78
  findings?: unknown[];
79
79
  }): string;
80
+ /**
81
+ * Claude Code hook JSON for the "API key not usable" state.
82
+ *
83
+ * A missing or rejected key is a setup problem, not a compliance failure —
84
+ * so instead of blocking every edit with an error, we emit a `systemMessage`
85
+ * (shown to the user, never fed to the agent, never blocking) telling them
86
+ * how to fix it. `reason` distinguishes a key that is absent (`'missing'`)
87
+ * from one the API rejected (`'rejected'` — expired/revoked/wrong) so the
88
+ * message points at the right fix. The caller exits 0. Pure so it's
89
+ * unit-testable.
90
+ */
91
+ export declare function formatClaudeHookSetupNotice(reason?: 'missing' | 'rejected'): string;
package/dist/cli.js CHANGED
@@ -38,6 +38,7 @@ exports.isCiEnvironment = isCiEnvironment;
38
38
  exports.resolveHookFileKey = resolveHookFileKey;
39
39
  exports.isClaudeCodeHookPayload = isClaudeCodeHookPayload;
40
40
  exports.formatClaudeHookOutput = formatClaudeHookOutput;
41
+ exports.formatClaudeHookSetupNotice = formatClaudeHookSetupNotice;
41
42
  const commander_1 = require("commander");
42
43
  const child_process_1 = require("child_process");
43
44
  const fs = __importStar(require("fs"));
@@ -46,6 +47,7 @@ const index_1 = require("./index");
46
47
  const table_1 = require("./formatters/table");
47
48
  const sarif_1 = require("./formatters/sarif");
48
49
  const prompt_1 = require("./formatters/prompt");
50
+ const config_1 = require("./utils/config");
49
51
  const KNOWN_COMMANDS = new Set([
50
52
  'scan',
51
53
  'scans',
@@ -342,6 +344,8 @@ program
342
344
  .option('--api-url <url>', 'Compliance API base URL (or PC_API_URL env)')
343
345
  .option('--api-key <key>', 'API key for compliance API (or PC_API_KEY env)')
344
346
  .action(async (opts) => {
347
+ // Hoisted so the catch block can branch on it (see the auth-error case).
348
+ let claudeHook = false;
345
349
  try {
346
350
  const frameworks = parseList(opts.framework) ?? ['soc2', 'hipaa', 'nist-csf'];
347
351
  const format = (opts.format ?? 'prompt');
@@ -351,13 +355,23 @@ program
351
355
  process.exit(0);
352
356
  return;
353
357
  }
358
+ claudeHook = collected.claudeHook;
359
+ // Graceful unconfigured state: a missing API key is a setup problem,
360
+ // not a compliance failure. For a Claude Code hook, surface a one-line
361
+ // setup notice and exit 0 so the agent is not blocked on every edit.
362
+ // Non-Claude callers (CI) fall through and hard-fail as before.
363
+ if (claudeHook && !(0, config_1.resolveApiKey)(opts.apiKey)) {
364
+ process.stdout.write(formatClaudeHookSetupNotice('missing') + '\n');
365
+ process.exit(0);
366
+ return;
367
+ }
354
368
  const response = await (0, index_1.gate)({
355
369
  files: collected.files,
356
370
  frameworks,
357
371
  apiUrl: opts.apiUrl,
358
372
  apiKey: opts.apiKey,
359
373
  });
360
- if (collected.claudeHook && response.exitCode !== 2) {
374
+ if (claudeHook && response.exitCode !== 2) {
361
375
  // Invoked as a Claude Code PostToolUse hook. Emit Claude Code's
362
376
  // native hook JSON on stdout so a violation is fed back to the
363
377
  // agent as actionable feedback — this is what closes the compliance
@@ -379,6 +393,14 @@ program
379
393
  process.exit(response.exitCode);
380
394
  }
381
395
  catch (error) {
396
+ // A rejected key (401/403) is a setup problem too \u2014 on the Claude Code
397
+ // hook path, treat it like the missing-key case rather than blocking
398
+ // every edit with an error.
399
+ if (claudeHook && isAuthError(error)) {
400
+ process.stdout.write(formatClaudeHookSetupNotice('rejected') + '\n');
401
+ process.exit(0);
402
+ return;
403
+ }
382
404
  console.error(`\u2717 Error: ${error.message}`);
383
405
  process.exit(2);
384
406
  }
@@ -462,6 +484,35 @@ function formatClaudeHookOutput(response) {
462
484
  : (0, prompt_1.formatPrompt)(response);
463
485
  return JSON.stringify({ decision: 'block', reason });
464
486
  }
487
+ /**
488
+ * Claude Code hook JSON for the "API key not usable" state.
489
+ *
490
+ * A missing or rejected key is a setup problem, not a compliance failure —
491
+ * so instead of blocking every edit with an error, we emit a `systemMessage`
492
+ * (shown to the user, never fed to the agent, never blocking) telling them
493
+ * how to fix it. `reason` distinguishes a key that is absent (`'missing'`)
494
+ * from one the API rejected (`'rejected'` — expired/revoked/wrong) so the
495
+ * message points at the right fix. The caller exits 0. Pure so it's
496
+ * unit-testable.
497
+ */
498
+ function formatClaudeHookSetupNotice(reason = 'missing') {
499
+ const detail = reason === 'rejected'
500
+ ? 'the configured API key was rejected (it may be expired or revoked)'
501
+ : 'no API key found';
502
+ return JSON.stringify({
503
+ systemMessage: `ProdCycle compliance scanning is inactive — ${detail}. ` +
504
+ 'Run `prodcycle init --api-key pc_...` to save a valid key, or set the ' +
505
+ 'PC_API_KEY environment variable. See https://docs.prodcycle.com.',
506
+ });
507
+ }
508
+ /**
509
+ * True when an error is the API rejecting our credentials (401/403) — i.e.
510
+ * a key that is missing, wrong, or revoked. Treated as a setup problem, not
511
+ * a scan failure, on the Claude Code hook path.
512
+ */
513
+ function isAuthError(error) {
514
+ return error instanceof index_1.ApiError && (error.statusCode === 401 || error.statusCode === 403);
515
+ }
465
516
  async function collectHookFiles(filePath) {
466
517
  if (filePath) {
467
518
  const absolute = path.resolve(filePath);
@@ -507,14 +558,51 @@ async function collectHookFiles(filePath) {
507
558
  const candidate = payload?.tool_input ?? payload;
508
559
  const hookFilePath = candidate?.file_path ?? candidate?.path;
509
560
  const hookContent = candidate?.content ?? candidate?.new_string;
510
- if (hookFilePath && typeof hookContent === 'string') {
511
- return { files: { [hookFilePath]: hookContent }, claudeHook };
561
+ // Coding agents (Claude Code's PostToolUse in particular) pass the file
562
+ // path as an absolute path. The compliance API rejects absolute paths
563
+ // (`File path must be relative`), so we relativise here against cwd's
564
+ // realpath — same code path as `--file <path>`, just driven by the stdin
565
+ // payload. Without this fix every PostToolUse hook call from Claude Code
566
+ // failed with "File path must be relative".
567
+ let hookFileKey = hookFilePath;
568
+ if (hookFilePath && path.isAbsolute(hookFilePath)) {
569
+ try {
570
+ const realpathFile = fs.realpathSync(path.resolve(hookFilePath));
571
+ const realpathCwd = fs.realpathSync(process.cwd());
572
+ const resolved = resolveHookFileKey(hookFilePath, realpathFile, realpathCwd);
573
+ if (!resolved.ok) {
574
+ // The helper's message references `--file`; rewrite for the stdin call site.
575
+ console.error(resolved.error.replace('hook: --file ', 'hook: file_path '));
576
+ process.exit(2);
577
+ }
578
+ hookFileKey = resolved.key;
579
+ }
580
+ catch (err) {
581
+ // File may not exist on disk yet (e.g. a Write event mid-creation) —
582
+ // ONLY ENOENT triggers the lexical fallback. Re-throw permission /
583
+ // symlink-loop / other fs errors so a real problem isn't silently
584
+ // converted into a degraded lexical-only key.
585
+ const code = err?.code;
586
+ if (code !== 'ENOENT') {
587
+ throw err;
588
+ }
589
+ const rel = path.relative(process.cwd(), hookFilePath);
590
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
591
+ console.error(`hook: file_path ${hookFilePath} is outside the current directory ` +
592
+ `(${process.cwd()}). Pass a path relative to the repo root.`);
593
+ process.exit(2);
594
+ }
595
+ hookFileKey = rel;
596
+ }
597
+ }
598
+ if (hookFileKey && typeof hookContent === 'string') {
599
+ return { files: { [hookFileKey]: hookContent }, claudeHook };
512
600
  }
513
- if (hookFilePath && fs.existsSync(hookFilePath)) {
601
+ if (hookFilePath && hookFileKey && fs.existsSync(hookFilePath)) {
514
602
  // Only a path was given — read from disk so post-edit hooks still work
515
603
  // when the agent doesn't ship the content inline.
516
604
  const content = fs.readFileSync(hookFilePath, 'utf8');
517
- return { files: { [hookFilePath]: content }, claudeHook };
605
+ return { files: { [hookFileKey]: content }, claudeHook };
518
606
  }
519
607
  console.error('hook: stdin payload not recognized. Expected one of:\n' +
520
608
  ' {"files": {"path": "content"}}\n' +
@@ -531,16 +619,28 @@ program
531
619
  .option('--ci <providers>', 'Comma-separated CI providers to scaffold (github, gitlab, circleci). Use "all" for every provider. Opt-in only \u2014 never auto-detected.')
532
620
  .option('--force', 'Overwrite existing compliance hook entries')
533
621
  .option('--dir <path>', 'Project directory to configure', '.')
622
+ .option('--api-key <key>', 'Save this API key to the user config file (~/.config/prodcycle/config.json, chmod 600) so hooks authenticate without an env var')
534
623
  .action((opts) => {
535
624
  try {
536
625
  const dir = path.resolve(opts.dir ?? '.');
626
+ // Persist the API key first so the post-install hints below see it.
627
+ if (opts.apiKey) {
628
+ const savedTo = (0, config_1.writeApiKey)(opts.apiKey);
629
+ process.stdout.write(`[config] saved API key to ${savedTo} (readable only by you).\n`);
630
+ }
537
631
  const agents = resolveAgents(opts.agent, dir);
538
632
  const ciProviders = resolveCiProviders(opts.ci);
539
633
  if (agents.length === 0 && ciProviders.length === 0) {
634
+ if (opts.apiKey) {
635
+ // Saving the key was itself the requested action — nothing else to do.
636
+ process.exit(0);
637
+ return;
638
+ }
540
639
  console.error('init: nothing to do. ' +
541
640
  'Use --agent <name> to configure a coding agent (claude, cursor, codex, ' +
542
- 'opencode, github-copilot, gemini-cli, or "all"), and/or --ci <provider> ' +
543
- 'to scaffold CI workflows (github, gitlab, circleci, or "all"). ' +
641
+ 'opencode, github-copilot, gemini-cli, or "all"), --ci <provider> ' +
642
+ 'to scaffold CI workflows (github, gitlab, circleci, or "all"), ' +
643
+ 'and/or --api-key <key> to save your credentials. ' +
544
644
  'Without --agent the CLI also auto-detects agents already in use.');
545
645
  process.exit(2);
546
646
  }
@@ -628,20 +728,19 @@ function configureAgent(agent, dir, force, writtenPaths) {
628
728
  const CLAUDE_MATCHER = 'Write|Edit|MultiEdit';
629
729
  const CLAUDE_COMMAND = 'prodcycle hook';
630
730
  /**
631
- * Build the post-install hint about `PC_API_KEY`. The hook calls the hosted
632
- * API, which needs the key in the environment the agent runs in the most
633
- * common reason a freshly-installed hook fails. We can only inspect the shell
634
- * running `init`, which is NOT necessarily the environment the agent runs in
635
- * (e.g. a macOS GUI-launched editor does not inherit shell exports), so the
636
- * "set" branch points the user at making the key available there too.
731
+ * Build the post-install hint about API-key setup. The hook authenticates
732
+ * against the hosted API using a key resolved from `--api-key`, the
733
+ * `PC_API_KEY` env var, or the user config file (see `utils/config.ts`).
734
+ * The config file is the robust option unlike an env var, a GUI-launched
735
+ * editor picks it up with no relaunch so when no key is found we point
736
+ * the user there.
637
737
  */
638
- function apiKeyHint(agentLabel) {
639
- return process.env.PC_API_KEY
640
- ? `PC_API_KEY is set in this shell — make sure it is also available in ` +
641
- `the environment ${agentLabel} runs in (e.g. add it to your shell profile).`
642
- : `⚠ Set PC_API_KEY before launching ${agentLabel} run ` +
643
- '`export PC_API_KEY=pc_...` in that shell (or add it to your shell ' +
644
- 'profile). The hook calls the hosted API and fails without it.';
738
+ function apiKeyHint() {
739
+ return (0, config_1.resolveApiKey)()
740
+ ? 'API key configured'
741
+ : '⚠ No API key configured run `prodcycle init --api-key pc_...` to ' +
742
+ 'save one (or set the PC_API_KEY env var). The hook calls the hosted ' +
743
+ 'API and fails without it.';
645
744
  }
646
745
  function configureClaudeCode(dir, force) {
647
746
  const claudeDir = path.join(dir, '.claude');
@@ -690,7 +789,7 @@ function configureClaudeCode(dir, force) {
690
789
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
691
790
  return {
692
791
  status: 'installed',
693
- message: `[claude] wrote PostToolUse hook to ${settingsPath}. ${apiKeyHint('Claude Code')}`,
792
+ message: `[claude] wrote PostToolUse hook to ${settingsPath}. ${apiKeyHint()}`,
694
793
  };
695
794
  }
696
795
  const CURSOR_COMMAND = 'prodcycle hook';
@@ -738,7 +837,7 @@ function configureCursor(dir, force) {
738
837
  fs.writeFileSync(hooksPath, JSON.stringify(config, null, 2) + '\n');
739
838
  return {
740
839
  status: 'installed',
741
- message: `[cursor] wrote afterFileEdit hook to ${hooksPath}. ${apiKeyHint('Cursor')}`,
840
+ message: `[cursor] wrote afterFileEdit hook to ${hooksPath}. ${apiKeyHint()}`,
742
841
  };
743
842
  }
744
843
  // ── Instruction-file agents (codex, opencode, github-copilot, gemini-cli) ───
@@ -0,0 +1,37 @@
1
+ export interface ProdcycleConfig {
2
+ api_key?: string;
3
+ api_url?: string;
4
+ }
5
+ /**
6
+ * Path to the user-level config file:
7
+ * `$XDG_CONFIG_HOME/prodcycle/config.json`, falling back to
8
+ * `~/.config/prodcycle/config.json`. A non-absolute `XDG_CONFIG_HOME` is
9
+ * ignored, per the XDG Base Directory spec.
10
+ */
11
+ export declare function configFilePath(): string;
12
+ /**
13
+ * Read the user-level config file. Returns `{}` when the file is absent,
14
+ * unreadable, or malformed — a broken config must never crash a scan.
15
+ */
16
+ export declare function readConfig(): ProdcycleConfig;
17
+ /**
18
+ * Persist the API key to the user-level config file, merging into any keys
19
+ * already present. The file is written `0600` (directory `0700`) so the
20
+ * credential is not world-readable. Returns the path written.
21
+ */
22
+ export declare function writeApiKey(apiKey: string): string;
23
+ /**
24
+ * Resolve the API key, in precedence order: an explicit value (CLI
25
+ * `--api-key`), the `PC_API_KEY` env var, then the user-level config file.
26
+ * Returns `''` when none is configured.
27
+ *
28
+ * Pass an already-read `config` to avoid re-reading the file when the
29
+ * caller resolves several values at once (see `ComplianceApiClient`).
30
+ */
31
+ export declare function resolveApiKey(explicit?: string, config?: ProdcycleConfig): string;
32
+ /**
33
+ * Resolve the API URL: explicit value → `PC_API_URL` env → config file →
34
+ * `undefined` (the caller then applies its own default). Accepts an
35
+ * already-read `config` — see `resolveApiKey`.
36
+ */
37
+ export declare function resolveApiUrl(explicit?: string, config?: ProdcycleConfig): string | undefined;
@@ -0,0 +1,129 @@
1
+ "use strict";
2
+ // User-level ProdCycle configuration.
3
+ //
4
+ // Lets a developer save their API key once, on disk, instead of exporting
5
+ // `PC_API_KEY` into every shell — and, critically, into the environment a
6
+ // GUI-launched editor runs in, which does NOT inherit shell exports. The
7
+ // hook reads this file regardless of how the agent was launched, so
8
+ // `prodcycle init --api-key pc_...` is a one-time setup with no relaunch.
9
+ //
10
+ // Mirrors `python/src/prodcycle/utils/config.py` — the two must stay in
11
+ // lockstep.
12
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
13
+ if (k2 === undefined) k2 = k;
14
+ var desc = Object.getOwnPropertyDescriptor(m, k);
15
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
16
+ desc = { enumerable: true, get: function() { return m[k]; } };
17
+ }
18
+ Object.defineProperty(o, k2, desc);
19
+ }) : (function(o, m, k, k2) {
20
+ if (k2 === undefined) k2 = k;
21
+ o[k2] = m[k];
22
+ }));
23
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
24
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
25
+ }) : function(o, v) {
26
+ o["default"] = v;
27
+ });
28
+ var __importStar = (this && this.__importStar) || (function () {
29
+ var ownKeys = function(o) {
30
+ ownKeys = Object.getOwnPropertyNames || function (o) {
31
+ var ar = [];
32
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
33
+ return ar;
34
+ };
35
+ return ownKeys(o);
36
+ };
37
+ return function (mod) {
38
+ if (mod && mod.__esModule) return mod;
39
+ var result = {};
40
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
41
+ __setModuleDefault(result, mod);
42
+ return result;
43
+ };
44
+ })();
45
+ Object.defineProperty(exports, "__esModule", { value: true });
46
+ exports.configFilePath = configFilePath;
47
+ exports.readConfig = readConfig;
48
+ exports.writeApiKey = writeApiKey;
49
+ exports.resolveApiKey = resolveApiKey;
50
+ exports.resolveApiUrl = resolveApiUrl;
51
+ const fs = __importStar(require("fs"));
52
+ const os = __importStar(require("os"));
53
+ const path = __importStar(require("path"));
54
+ // One-shot guard so a malformed config file warns at most once per process
55
+ // (both `resolveApiKey` and `resolveApiUrl` call `readConfig`).
56
+ let warnedMalformed = false;
57
+ /**
58
+ * Path to the user-level config file:
59
+ * `$XDG_CONFIG_HOME/prodcycle/config.json`, falling back to
60
+ * `~/.config/prodcycle/config.json`. A non-absolute `XDG_CONFIG_HOME` is
61
+ * ignored, per the XDG Base Directory spec.
62
+ */
63
+ function configFilePath() {
64
+ const xdg = process.env.XDG_CONFIG_HOME;
65
+ const base = xdg && path.isAbsolute(xdg) ? xdg : path.join(os.homedir(), '.config');
66
+ return path.join(base, 'prodcycle', 'config.json');
67
+ }
68
+ /**
69
+ * Read the user-level config file. Returns `{}` when the file is absent,
70
+ * unreadable, or malformed — a broken config must never crash a scan.
71
+ */
72
+ function readConfig() {
73
+ let raw;
74
+ try {
75
+ raw = fs.readFileSync(configFilePath(), 'utf8');
76
+ }
77
+ catch {
78
+ return {}; // absent / unreadable — the expected case, not an error
79
+ }
80
+ try {
81
+ const parsed = JSON.parse(raw);
82
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
83
+ return parsed;
84
+ }
85
+ }
86
+ catch {
87
+ // fall through to the warning below
88
+ }
89
+ if (!warnedMalformed) {
90
+ warnedMalformed = true;
91
+ process.stderr.write(`Warning: ${configFilePath()} is not valid JSON — ignoring it.\n`);
92
+ }
93
+ return {};
94
+ }
95
+ /**
96
+ * Persist the API key to the user-level config file, merging into any keys
97
+ * already present. The file is written `0600` (directory `0700`) so the
98
+ * credential is not world-readable. Returns the path written.
99
+ */
100
+ function writeApiKey(apiKey) {
101
+ const file = configFilePath();
102
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
103
+ const config = readConfig();
104
+ config.api_key = apiKey;
105
+ fs.writeFileSync(file, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
106
+ // `mode` on writeFileSync is ignored when the file already exists, so set
107
+ // it explicitly — the credential must never be left world-readable.
108
+ fs.chmodSync(file, 0o600);
109
+ return file;
110
+ }
111
+ /**
112
+ * Resolve the API key, in precedence order: an explicit value (CLI
113
+ * `--api-key`), the `PC_API_KEY` env var, then the user-level config file.
114
+ * Returns `''` when none is configured.
115
+ *
116
+ * Pass an already-read `config` to avoid re-reading the file when the
117
+ * caller resolves several values at once (see `ComplianceApiClient`).
118
+ */
119
+ function resolveApiKey(explicit, config) {
120
+ return explicit || process.env.PC_API_KEY || (config ?? readConfig()).api_key || '';
121
+ }
122
+ /**
123
+ * Resolve the API URL: explicit value → `PC_API_URL` env → config file →
124
+ * `undefined` (the caller then applies its own default). Accepts an
125
+ * already-read `config` — see `resolveApiKey`.
126
+ */
127
+ function resolveApiUrl(explicit, config) {
128
+ return explicit || process.env.PC_API_URL || (config ?? readConfig()).api_url || undefined;
129
+ }
package/dist/utils/fs.js CHANGED
@@ -335,7 +335,13 @@ async function collectFiles(baseDir, includePatterns, excludePatterns) {
335
335
  const files = {};
336
336
  const state = { count: 0, limitReached: false };
337
337
  walk(repoRoot, repoRoot, gitignores, prodcycleIgnores, includePatterns, excludePatterns, files, state);
338
- return files;
338
+ // Canonical sort by path. Object iteration order is insertion order, which
339
+ // reflects directory-walk order — that differs between Node's `readdirSync`
340
+ // and Python's `os.walk`, so the two CLIs would otherwise chunk the same
341
+ // file set into different request shapes and the server-side per-chunk
342
+ // evaluation produced subtle finding divergences (CLAUDE.md "Node and
343
+ // Python must stay symmetric"). Sorting here makes the wire shape identical.
344
+ return Object.fromEntries(Object.entries(files).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
339
345
  }
340
346
  function walk(dir, repoRoot, gitignores, prodcycleIgnores, includePatterns, userExcludes, files, state) {
341
347
  if (state.limitReached)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prodcycle/prodcycle",
3
- "version": "0.6.7",
3
+ "version": "0.6.9",
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": {