@activemind/scd 1.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.
Files changed (79) hide show
  1. package/LICENSE.md +35 -0
  2. package/README.md +417 -0
  3. package/bin/scd.js +140 -0
  4. package/lib/audit-report.js +93 -0
  5. package/lib/audit-sync.js +172 -0
  6. package/lib/audit.js +356 -0
  7. package/lib/cli-helpers.js +108 -0
  8. package/lib/commands/accept.js +28 -0
  9. package/lib/commands/audit.js +17 -0
  10. package/lib/commands/configure.js +200 -0
  11. package/lib/commands/doctor.js +14 -0
  12. package/lib/commands/exceptions.js +19 -0
  13. package/lib/commands/export-findings.js +46 -0
  14. package/lib/commands/findings.js +306 -0
  15. package/lib/commands/ignore.js +28 -0
  16. package/lib/commands/init.js +16 -0
  17. package/lib/commands/insights.js +24 -0
  18. package/lib/commands/install.js +15 -0
  19. package/lib/commands/list.js +109 -0
  20. package/lib/commands/remove.js +16 -0
  21. package/lib/commands/repo.js +862 -0
  22. package/lib/commands/report.js +234 -0
  23. package/lib/commands/resolve.js +25 -0
  24. package/lib/commands/rules.js +185 -0
  25. package/lib/commands/scan.js +519 -0
  26. package/lib/commands/scope.js +341 -0
  27. package/lib/commands/sync.js +40 -0
  28. package/lib/commands/uninstall.js +15 -0
  29. package/lib/commands/version.js +33 -0
  30. package/lib/comment-map.js +388 -0
  31. package/lib/config.js +325 -0
  32. package/lib/context-modifiers.js +211 -0
  33. package/lib/deep-analyzer.js +225 -0
  34. package/lib/doctor.js +236 -0
  35. package/lib/exception-manager.js +675 -0
  36. package/lib/export-findings.js +376 -0
  37. package/lib/file-context.js +380 -0
  38. package/lib/file-filter.js +204 -0
  39. package/lib/file-manifest.js +145 -0
  40. package/lib/git-utils.js +102 -0
  41. package/lib/global-config.js +239 -0
  42. package/lib/hooks-manager.js +130 -0
  43. package/lib/init-repo.js +147 -0
  44. package/lib/insights-analyzer.js +416 -0
  45. package/lib/insights-output.js +160 -0
  46. package/lib/installer.js +128 -0
  47. package/lib/output-constants.js +32 -0
  48. package/lib/output-terminal.js +407 -0
  49. package/lib/push-queue.js +322 -0
  50. package/lib/remove-repo.js +108 -0
  51. package/lib/repo-context.js +187 -0
  52. package/lib/report-html.js +1154 -0
  53. package/lib/report-index.js +157 -0
  54. package/lib/report-json.js +136 -0
  55. package/lib/report-markdown.js +250 -0
  56. package/lib/resolve-manager.js +148 -0
  57. package/lib/rule-registry.js +205 -0
  58. package/lib/scan-cache.js +171 -0
  59. package/lib/scan-context.js +312 -0
  60. package/lib/scan-schema.js +67 -0
  61. package/lib/scanner-full.js +681 -0
  62. package/lib/scanner-manual.js +348 -0
  63. package/lib/scanner-secrets.js +83 -0
  64. package/lib/scope.js +331 -0
  65. package/lib/store-verify.js +395 -0
  66. package/lib/store.js +310 -0
  67. package/lib/taint-register.js +196 -0
  68. package/lib/version-check.js +46 -0
  69. package/package.json +37 -0
  70. package/rules/rule-loader.js +324 -0
  71. package/rules/rules-aspx-cs.json +399 -0
  72. package/rules/rules-aspx.json +222 -0
  73. package/rules/rules-infra-leakage.json +434 -0
  74. package/rules/rules-js.json +664 -0
  75. package/rules/rules-php.json +521 -0
  76. package/rules/rules-python.json +466 -0
  77. package/rules/rules-secrets.json +99 -0
  78. package/rules/rules-sensitive-files.json +475 -0
  79. package/rules/rules-ts.json +76 -0
@@ -0,0 +1,205 @@
1
+ /**
2
+ * rule-registry.js
3
+ * Central catalogue of all Secure Code by Design rules.
4
+ *
5
+ * Normalises every rule into a consistent shape:
6
+ * { id, name, severity, category, languages, matchMode, why, scenario, fix }
7
+ *
8
+ * Used by: scd rules (list/search/detail)
9
+ * scd report (rule metadata in reports)
10
+ * scd insights (category grouping)
11
+ */
12
+
13
+ 'use strict';
14
+
15
+ // Rules bundle version – bump on rule additions/fixes (minor) or ID changes (major)
16
+ // Independent of CLI version: scd 0.2.0 can ship with rules 1.3.0
17
+ const RULES_VERSION = '1.2.0';
18
+
19
+ const { loadRule } = require('../rules/rule-loader');
20
+ const _jsPack = require('../rules/rules-js.json');
21
+ const JS_RULES = _jsPack.rules.filter(r => r.severity !== 'EXPOSURE').map(r => loadRule(r, 'builtin'));
22
+ const JS_EXPOSURE = _jsPack.rules.filter(r => r.severity === 'EXPOSURE').map(r => loadRule(r, 'builtin'));
23
+ const _tsPack = require('../rules/rules-ts.json');
24
+ const TS_RULES = _tsPack.rules.map(r => loadRule(r, 'builtin'));
25
+ const _pyPack = require('../rules/rules-python.json');
26
+ const _phpPack = require('../rules/rules-php.json');
27
+ const PY_RULES = _pyPack.rules.map(r => loadRule(r, 'builtin'));
28
+ const PHP_RULES = _phpPack.rules.map(r => loadRule(r, 'builtin'));
29
+ const PY_EXPOSURE = PY_RULES.filter(r => r.severity === 'EXPOSURE');
30
+ const PHP_EXPOSURE = PHP_RULES.filter(r => r.severity === 'EXPOSURE');
31
+ const _aspxPack = require('../rules/rules-aspx.json');
32
+ const _aspxCsPack = require('../rules/rules-aspx-cs.json');
33
+ const ASPX_RULES = _aspxPack.rules.map(r => loadRule(r, 'builtin'));
34
+ const ASPX_CS_RULES = _aspxCsPack.rules.map(r => loadRule(r, 'builtin'));
35
+ const _sensitivePack = require('../rules/rules-sensitive-files.json');
36
+ const _sensitiveRules = _sensitivePack.rules.map(r => loadRule(r, 'builtin'));
37
+ const SF_CONTENT = _sensitiveRules.filter(r => r.matchMode !== 'filename');
38
+ const SF_FILENAME = _sensitiveRules.filter(r => r.matchMode === 'filename');
39
+ const _infraPack = require('../rules/rules-infra-leakage.json');
40
+ const ALL_INFRA_RULES = _infraPack.rules.map(r => loadRule(r, 'builtin'));
41
+ const { loadPack } = require('../rules/rule-loader');
42
+ const _secretsPack = require('../rules/rules-secrets.json');
43
+ const SECRET_RULES = loadPack(_secretsPack);
44
+
45
+ // ── Language tags per rule source ──────────────────────────────────────────
46
+
47
+ const SOURCES = [
48
+ { rules: JS_RULES, languages: ['js', 'ts', 'mjs', 'cjs', 'jsx', 'tsx'] },
49
+ { rules: JS_EXPOSURE, languages: ['js', 'ts', 'mjs', 'cjs', 'jsx', 'tsx', 'html', 'php', 'py'] },
50
+ { rules: TS_RULES, languages: ['ts', 'tsx'] },
51
+ { rules: PY_RULES, languages: ['py'] },
52
+ { rules: PY_EXPOSURE, languages: ['py'] },
53
+ { rules: PHP_RULES, languages: ['php'] },
54
+ { rules: PHP_EXPOSURE, languages: ['php'] },
55
+ { rules: ASPX_RULES, languages: ['aspx', 'ascx', 'cs'] },
56
+ { rules: ASPX_CS_RULES,languages: ['cs'] },
57
+ { rules: SF_CONTENT, languages: null }, // fileTypes on each rule
58
+ { rules: SF_FILENAME, languages: null }, // filename-match rules
59
+ { rules: ALL_INFRA_RULES, languages: ['all'] },
60
+ { rules: SECRET_RULES, languages: ['all'] },
61
+ ];
62
+
63
+ // ── Severity sort order ────────────────────────────────────────────────────
64
+
65
+ const SEV_ORDER = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, EXPOSURE: 3, LOW: 4 };
66
+
67
+ // ── Build normalised registry ──────────────────────────────────────────────
68
+
69
+ function buildRegistry() {
70
+ const seen = new Set();
71
+ const registry = [];
72
+
73
+ for (const { rules, languages } of SOURCES) {
74
+ if (!rules) continue;
75
+ for (const rule of rules) {
76
+ if (seen.has(rule.id)) continue; // dedup (e.g. EXPOSURE rules appear in multiple sets)
77
+ seen.add(rule.id);
78
+
79
+ // Determine languages for this rule
80
+ let langs;
81
+ if (languages) {
82
+ langs = languages;
83
+ } else if (rule.fileTypes) {
84
+ langs = rule.fileTypes;
85
+ } else {
86
+ langs = ['all'];
87
+ }
88
+
89
+ registry.push({
90
+ id: rule.id,
91
+ name: rule.name,
92
+ severity: rule.severity,
93
+ category: rule.category || 'Uncategorised',
94
+ languages: langs,
95
+ matchMode: rule.matchMode === 'filename' || rule.filenamePattern ? 'filename' : 'content',
96
+ why: rule.why || null,
97
+ scenario: rule.scenario || null,
98
+ fix: rule.fix || null,
99
+ checklist: rule.checklist || null,
100
+ });
101
+ }
102
+ }
103
+
104
+ // Sort: severity → id prefix → numeric suffix
105
+ registry.sort((a, b) => {
106
+ const sevDiff = (SEV_ORDER[a.severity] ?? 9) - (SEV_ORDER[b.severity] ?? 9);
107
+ if (sevDiff !== 0) return sevDiff;
108
+ return a.id.localeCompare(b.id, undefined, { numeric: true });
109
+ });
110
+
111
+ return registry;
112
+ }
113
+
114
+ // Lazy singleton
115
+ let _registry = null;
116
+ function getRegistry() {
117
+ if (!_registry) _registry = buildRegistry();
118
+ return _registry;
119
+ }
120
+
121
+ /**
122
+ * Look up a single rule by ID, including compiled pattern and antipattern.
123
+ * Used by export-findings (review-rules) to include rule internals in output.
124
+ * Returns the compiled rule object from the first matching source, or null.
125
+ */
126
+ function getRuleById(id) {
127
+ const allSources = [
128
+ JS_RULES, JS_EXPOSURE,
129
+ TS_RULES,
130
+ PY_RULES, PY_EXPOSURE,
131
+ PHP_RULES, PHP_EXPOSURE,
132
+ ASPX_RULES, ASPX_CS_RULES,
133
+ SF_CONTENT, SF_FILENAME,
134
+ ALL_INFRA_RULES,
135
+ SECRET_RULES,
136
+ ];
137
+ for (const source of allSources) {
138
+ if (!Array.isArray(source)) continue;
139
+ const rule = source.find(r => r.id === id);
140
+ if (rule) return rule;
141
+ }
142
+ return null;
143
+ }
144
+
145
+ // ── Query helpers ──────────────────────────────────────────────────────────
146
+
147
+ /**
148
+ * Filter registry by options.
149
+ * @param {{ lang, severity, id, search }} opts
150
+ */
151
+ function queryRules({ lang, severity, id, search } = {}) {
152
+ let rules = getRegistry();
153
+
154
+ if (id) {
155
+ return rules.filter(r => r.id.toLowerCase() === id.toLowerCase());
156
+ }
157
+
158
+ if (lang) {
159
+ const langs = lang.split(',').map(l => l.trim().toLowerCase());
160
+ rules = rules.filter(r =>
161
+ r.languages.includes('all') ||
162
+ r.languages.some(l => langs.includes(l.toLowerCase()))
163
+ );
164
+ }
165
+
166
+ if (severity) {
167
+ const sev = severity.toUpperCase();
168
+ rules = rules.filter(r => r.severity === sev);
169
+ }
170
+
171
+ if (search) {
172
+ const q = search.toLowerCase();
173
+ rules = rules.filter(r =>
174
+ r.id.toLowerCase().includes(q) ||
175
+ r.name.toLowerCase().includes(q) ||
176
+ r.category.toLowerCase().includes(q) ||
177
+ (r.why && r.why.toLowerCase().includes(q)) ||
178
+ (r.fix && r.fix.toLowerCase().includes(q))
179
+ );
180
+ }
181
+
182
+ return rules;
183
+ }
184
+
185
+ /**
186
+ * Stats summary across all (or filtered) rules.
187
+ */
188
+ function getStats(rules) {
189
+ rules = rules || getRegistry();
190
+ const bySeverity = {};
191
+ const byLanguage = {};
192
+ const byCategory = {};
193
+
194
+ for (const r of rules) {
195
+ bySeverity[r.severity] = (bySeverity[r.severity] || 0) + 1;
196
+ for (const l of r.languages) {
197
+ byLanguage[l] = (byLanguage[l] || 0) + 1;
198
+ }
199
+ byCategory[r.category] = (byCategory[r.category] || 0) + 1;
200
+ }
201
+
202
+ return { total: rules.length, bySeverity, byLanguage, byCategory };
203
+ }
204
+
205
+ module.exports = { getRegistry, getRuleById, queryRules, getStats, SEV_ORDER, RULES_VERSION };
@@ -0,0 +1,171 @@
1
+ /**
2
+ * scan-cache.js
3
+ * Saves and loads scan data.
4
+ *
5
+ * Each scan is saved as an individual file in:
6
+ * ~/.scd/repos/{repoId}/scans/{scanId}.json
7
+ *
8
+ * last-scan.json is kept as a copy of the latest scan for backwards
9
+ * compatibility with scd report (no flags needed for the common case).
10
+ *
11
+ * Scan files are never overwritten — a new scanId is generated per run.
12
+ * Deep analysis results are stored alongside findings in the same file.
13
+ */
14
+
15
+ 'use strict';
16
+ const { RESET, DIM } = require('./output-constants');
17
+
18
+ const fs = require('fs');
19
+ const store = require('./store');
20
+ const { validateScan } = require('./scan-schema');
21
+
22
+ // ── Scan ID ────────────────────────────────────────────────────────────────
23
+
24
+ /**
25
+ * Generate a short random scan ID.
26
+ * Format: s-{8 hex chars} e.g. s-a3f9b2c1
27
+ *
28
+ * Deliberately not date/time-based — avoids timezone confusion.
29
+ * The actual scan timestamp lives in the file's scanDate field.
30
+ * Same ID is used as session_id on the server for full traceability.
31
+ */
32
+ function makeScanId() {
33
+ const crypto = require('crypto');
34
+ return 's-' + crypto.randomBytes(4).toString('hex');
35
+ }
36
+
37
+
38
+ // ── Build exclusions summary for scan JSON ─────────────────────────────────
39
+
40
+ /**
41
+ * Build the exclusions field for the scan JSON payload.
42
+ * Combines file exclusion metadata (from scanner-manual) with rule exclusion
43
+ * counts (from scanner-full._ruleExclusionCounts).
44
+ *
45
+ * Returns null if no exclusions were active.
46
+ */
47
+ function buildExclusionsSummary(scopeExclusions, findings) {
48
+ if (!scopeExclusions) return null;
49
+
50
+ const ruleExclusionCounts = findings?._ruleExclusionCounts || {};
51
+
52
+ const ruleExcludes = (scopeExclusions.rule_excludes || []).map(e => ({
53
+ rule: e.rule,
54
+ files: e.files || null,
55
+ findings_excluded: ruleExclusionCounts[e.rule] || 0,
56
+ source: e._source || 'repo',
57
+ reason: e.reason || null,
58
+ added_by: e.added_by || null,
59
+ added_at: e.added_at || null,
60
+ }));
61
+
62
+ const fileExcludes = (scopeExclusions.file_excludes || []).map(e => ({
63
+ pattern: e.pattern,
64
+ files_excluded: scopeExclusions.files_excluded || 0,
65
+ source: e._source || 'repo',
66
+ reason: e.reason || null,
67
+ added_by: e.added_by || null,
68
+ added_at: e.added_at || null,
69
+ }));
70
+
71
+ return {
72
+ files_excluded: scopeExclusions.files_excluded || 0,
73
+ file_excludes: fileExcludes,
74
+ rule_excludes: ruleExcludes,
75
+ };
76
+ }
77
+
78
+ // ── Save ───────────────────────────────────────────────────────────────────
79
+
80
+ /**
81
+ * Save scan results to the per-repo scans directory.
82
+ * Accepts an optional scanId — if not provided, generates a new one.
83
+ * Always returns the scanId used (pass it to logScan for consistency).
84
+ */
85
+ function saveCache(repoRoot, data, scanId) {
86
+ try {
87
+ const id = scanId || makeScanId();
88
+ const scanDate = data.scanDate || new Date();
89
+
90
+ store.updateMeta(repoRoot, {
91
+ findingCount: (data.findings || []).length,
92
+ criticalCount: (data.findings || []).filter(f => f.severity === 'CRITICAL').length,
93
+ });
94
+
95
+ const { getMachineFingerprint } = require('./store');
96
+ const os = require('os');
97
+
98
+ const payload = {
99
+ scanId: id,
100
+ scanDate: scanDate instanceof Date ? scanDate.toISOString() : scanDate,
101
+ installation_id: getMachineFingerprint(),
102
+ hostname: os.hostname(),
103
+ target: data.target || '.',
104
+ totalFiles: data.totalFiles || 0,
105
+ skipped: data.skipped || [],
106
+ findings: (data.findings || []).filter(f => f.ruleId),
107
+ suppressed_findings: (data.suppressed_findings || []).filter(f => f.ruleId),
108
+ deepResults: data.deepResults || null,
109
+ hasDeep: !!(data.deepResults && data.deepResults.length > 0),
110
+ repoRoot: data.repoRoot || null,
111
+ scanMode: data.scanMode || 'full',
112
+ exclusions: buildExclusionsSummary(data.scopeExclusions, data.findings),
113
+ };
114
+
115
+ validateScan(payload, 'saveCache');
116
+
117
+ const json = JSON.stringify(payload, null, 2);
118
+
119
+ // Save as individual scan file (never overwritten)
120
+ fs.writeFileSync(store.scanPath(repoRoot, id), json, { encoding: 'utf8', mode: 0o600 });
121
+
122
+ // Keep last-scan.json as a copy for backwards compatibility
123
+ fs.writeFileSync(store.scanCachePath(repoRoot), json, { encoding: 'utf8', mode: 0o600 });
124
+
125
+ return id;
126
+ } catch (err) {
127
+ console.error(`${DIM}[sc] Scan save warning: ${err.message}${RESET}`);
128
+ return null;
129
+ }
130
+ }
131
+
132
+ // ── Load ───────────────────────────────────────────────────────────────────
133
+
134
+ function loadCache(repoRoot) {
135
+ const cachePath = store.scanCachePath(repoRoot);
136
+ if (!fs.existsSync(cachePath)) return null;
137
+ try {
138
+ const data = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
139
+ if (!Array.isArray(data.findings)) return null;
140
+ return data;
141
+ } catch {
142
+ return null;
143
+ }
144
+ }
145
+
146
+ function loadScan(repoRoot, scanId) {
147
+ const scanFile = store.scanPath(repoRoot, scanId);
148
+ if (!fs.existsSync(scanFile)) return null;
149
+ try {
150
+ const data = JSON.parse(fs.readFileSync(scanFile, 'utf8'));
151
+ if (!Array.isArray(data.findings)) return null;
152
+ return data;
153
+ } catch {
154
+ return null;
155
+ }
156
+ }
157
+
158
+ // ── Human-readable age ─────────────────────────────────────────────────────
159
+
160
+ function cacheAge(isoDate) {
161
+ const diff = Date.now() - new Date(isoDate).getTime();
162
+ const mins = Math.floor(diff / 60000);
163
+ const hours = Math.floor(mins / 60);
164
+ const days = Math.floor(hours / 24);
165
+ if (days > 0) return `${days} day${days > 1 ? 's' : ''} ago`;
166
+ if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ago`;
167
+ if (mins > 0) return `${mins} minute${mins > 1 ? 's' : ''} ago`;
168
+ return 'just now';
169
+ }
170
+
171
+ module.exports = { saveCache, loadCache, loadScan, cacheAge, makeScanId };
@@ -0,0 +1,312 @@
1
+ 'use strict';
2
+ const { RESET, BOLD, DIM, RED, YELLOW, CYAN } = require('./output-constants');
3
+
4
+ /**
5
+ * lib/scan-context.js
6
+ *
7
+ * Resolves the correct repository context for a manual scan.
8
+ *
9
+ * Problem: scd scan always used CWD as repo context, regardless of where
10
+ * the scan target was. Scanning a file outside the current repo would
11
+ * contaminate the wrong repo with findings, or create a spurious new repo
12
+ * entry in the scd store.
13
+ *
14
+ * Solution: determine context from the target, not CWD. If the target is
15
+ * outside any known git repo, prompt the user rather than silently
16
+ * creating a bad repo entry.
17
+ *
18
+ * Non-interactive use:
19
+ * - No TTY detected: automatically scans without logging (pipeline-safe default).
20
+ * - --log-to none: always skip logging, no prompt.
21
+ * - --log-to current: log to CWD repo, no prompt (for cron/scheduled tasks).
22
+ * - --log-to target: log to target repo, no prompt (for cron/scheduled tasks).
23
+ */
24
+
25
+ const path = require('path');
26
+ const fs = require('fs');
27
+ const { execSync } = require('child_process');
28
+ const readline = require('readline');
29
+
30
+ /**
31
+ * Find the git root for a given path (file or directory).
32
+ * Returns null if the path is not inside a git repo.
33
+ */
34
+ function findGitRoot(targetPath) {
35
+ try {
36
+ const dir = fs.existsSync(targetPath) && fs.statSync(targetPath).isDirectory()
37
+ ? targetPath
38
+ : path.dirname(targetPath);
39
+
40
+ return execSync('git rev-parse --show-toplevel', {
41
+ cwd: dir, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
42
+ }).trim();
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Ask the user a question and return their answer.
50
+ */
51
+ function prompt(question) {
52
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
53
+ return new Promise(resolve => {
54
+ rl.question(question, answer => {
55
+ rl.close();
56
+ resolve(answer.trim());
57
+ });
58
+ });
59
+ }
60
+
61
+ /**
62
+ * Resolve the repo context for a manual scan.
63
+ *
64
+ * @param {string[]} targetList — resolved absolute paths of scan targets
65
+ * @param {string} cwdRepoRoot — git root of CWD (may be null)
66
+ * @param {object} opts
67
+ * @param {string} opts.logTo — 'none' | 'current' | 'target' | undefined
68
+ *
69
+ * @returns {Promise<{
70
+ * repoRoot: string|null, — the repo root to use for logging/config
71
+ * skipLogging: boolean, — if true: no audit log, no server push
72
+ * cancelled: boolean, — if true: user chose to cancel
73
+ * }>}
74
+ */
75
+ async function resolveTargetContext(targetList, cwdRepoRoot, { logTo } = {}) {
76
+ // ── Non-interactive / --log-to handling ──────────────────────────────────
77
+ // Resolved before any prompts or git root discovery so pipelines exit fast.
78
+ const isInteractive = !!process.stdin.isTTY;
79
+
80
+ if (logTo === 'none') {
81
+ return { repoRoot: null, skipLogging: true, cancelled: false };
82
+ }
83
+
84
+ if (logTo === 'current') {
85
+ if (!cwdRepoRoot) {
86
+ process.stderr.write(`${RED} --log-to current: current directory is not a known scd repo.${RESET}\n`);
87
+ process.stderr.write(` Run ${CYAN}scd init${RESET} first, or use ${CYAN}--log-to none${RESET}.\n\n`);
88
+ process.exit(1);
89
+ }
90
+ return { repoRoot: cwdRepoRoot, skipLogging: false, cancelled: false };
91
+ }
92
+
93
+ if (!isInteractive && !logTo) {
94
+ // No TTY and no explicit --log-to: pipeline-safe default — scan without logging.
95
+ // Use --log-to current|target to log results from a non-interactive context.
96
+ process.stderr.write(`${DIM}ℹ Non-interactive mode: scanning without logging. Use --log-to current|target to log results.${RESET}\n`);
97
+ return { repoRoot: null, skipLogging: true, cancelled: false };
98
+ }
99
+ // ── end non-interactive handling ─────────────────────────────────────────
100
+
101
+ // Find git roots for all targets
102
+ const targetRoots = new Set();
103
+ for (const t of targetList) {
104
+ const root = findGitRoot(path.resolve(t));
105
+ if (root) targetRoots.add(root);
106
+ }
107
+
108
+ // Case A: all targets share the same git root as CWD — normal flow
109
+ if (cwdRepoRoot && targetRoots.size === 1 && targetRoots.has(cwdRepoRoot)) {
110
+ return { repoRoot: cwdRepoRoot, skipLogging: false, cancelled: false };
111
+ }
112
+
113
+ // Case B: targets are inside a different (but known) git repo
114
+ // e.g. scd scan ~/other-project/file.js from inside ~/my-project
115
+ if (targetRoots.size === 1) {
116
+ const targetRoot = [...targetRoots][0];
117
+ if (targetRoot !== cwdRepoRoot) {
118
+
119
+ // --log-to target: use target repo, no prompt
120
+ if (logTo === 'target') {
121
+ return { repoRoot: targetRoot, skipLogging: false, cancelled: false };
122
+ }
123
+
124
+ console.log(`\n${YELLOW}⚠️ Scan target is in a different repository than your current directory.${RESET}`);
125
+ console.log(`${DIM} CWD repo: ${cwdRepoRoot || '(none)'}${RESET}`);
126
+ console.log(`${DIM} Target repo: ${targetRoot}${RESET}\n`);
127
+
128
+ const cwdName = cwdRepoRoot ? path.basename(cwdRepoRoot) : null;
129
+ const targetName = path.basename(targetRoot);
130
+
131
+ console.log(` How would you like to proceed?\n`);
132
+ console.log(` ${CYAN}[1]${RESET} Log results to target repo ${DIM}(${targetName} — recommended)${RESET}`);
133
+ if (cwdRepoRoot) {
134
+ console.log(` ${CYAN}[2]${RESET} Log results to current repo ${DIM}(${cwdName})${RESET}`);
135
+ console.log(` ${CYAN}[3]${RESET} Scan without logging ${DIM}(results shown only, nothing saved)${RESET}`);
136
+ console.log(` ${CYAN}[4]${RESET} Cancel`);
137
+ } else {
138
+ console.log(` ${CYAN}[2]${RESET} Scan without logging ${DIM}(results shown only, nothing saved)${RESET}`);
139
+ console.log(` ${CYAN}[3]${RESET} Cancel`);
140
+ }
141
+
142
+ const choice = (await prompt(`\n Choice [1]: `)).trim() || '1';
143
+
144
+ const cancelChoice = cwdRepoRoot ? '4' : '3';
145
+ const noLogChoice = cwdRepoRoot ? '3' : '2';
146
+
147
+ if (choice === cancelChoice || choice.toLowerCase() === 'cancel' || choice.toLowerCase() === 'q') {
148
+ return { repoRoot: null, skipLogging: true, cancelled: true };
149
+ }
150
+ if (choice === noLogChoice) {
151
+ return { repoRoot: null, skipLogging: true, cancelled: false };
152
+ }
153
+ if (choice === '2' && cwdRepoRoot) {
154
+ console.log(`${DIM} Logging results to current repo: ${cwdName}${RESET}\n`);
155
+ return { repoRoot: cwdRepoRoot, skipLogging: false, cancelled: false };
156
+ }
157
+ // Default / choice 1: use target repo
158
+ return { repoRoot: targetRoot, skipLogging: false, cancelled: false };
159
+ }
160
+ }
161
+
162
+ // Case C: targets span multiple git repos — warn and use CWD if available
163
+ if (targetRoots.size > 1) {
164
+ console.log(`\n${YELLOW}⚠️ Scan targets span multiple repositories.${RESET}`);
165
+ console.log(`${DIM} Results will be logged to the current repo context.${RESET}\n`);
166
+ return { repoRoot: cwdRepoRoot, skipLogging: !cwdRepoRoot, cancelled: false };
167
+ }
168
+
169
+ // Case D: target is outside any git repo
170
+
171
+ const targetDisplay = targetList.length === 1
172
+ ? path.resolve(targetList[0])
173
+ : `${targetList.length} targets`;
174
+
175
+ // --log-to target: no git repo found for target — warn and fall back to none
176
+ if (logTo === 'target') {
177
+ process.stderr.write(`${YELLOW} --log-to target: scan target is outside any git repo. Scanning without logging.${RESET}\n`);
178
+ return { repoRoot: null, skipLogging: true, cancelled: false };
179
+ }
180
+
181
+ console.log(`\n${YELLOW}⚠️ Scan target is outside any known repository.${RESET}`);
182
+ console.log(`${DIM} Target: ${targetDisplay}${RESET}`);
183
+ console.log(`${DIM} CWD: ${process.cwd()}${cwdRepoRoot ? '' : ' (no scd repo)'}${RESET}\n`);
184
+
185
+ console.log(` How would you like to proceed?\n`);
186
+ console.log(` ${CYAN}[1]${RESET} Scan without logging ${DIM}(results shown only, nothing saved)${RESET}`);
187
+ if (cwdRepoRoot) {
188
+ console.log(` ${CYAN}[2]${RESET} Log results to current repo ${DIM}(${path.basename(cwdRepoRoot)})${RESET}`);
189
+ console.log(` ${CYAN}[3]${RESET} Cancel`);
190
+ } else {
191
+ console.log(` ${CYAN}[2]${RESET} Cancel`);
192
+ }
193
+
194
+ const maxChoice = cwdRepoRoot ? 3 : 2;
195
+ const cancelChoice = cwdRepoRoot ? '3' : '2';
196
+
197
+ const answer = await prompt(`\n Choice [1]: `);
198
+ const choice = answer === '' ? '1' : answer;
199
+
200
+ if (choice === cancelChoice || choice === 'cancel' || choice === 'q') {
201
+ return { repoRoot: null, skipLogging: true, cancelled: true };
202
+ }
203
+
204
+ if (choice === '2' && cwdRepoRoot) {
205
+ // Log to CWD repo
206
+ return { repoRoot: cwdRepoRoot, skipLogging: false, cancelled: false };
207
+ }
208
+
209
+ // Default / choice 1: scan without logging
210
+ return { repoRoot: null, skipLogging: true, cancelled: false };
211
+ }
212
+
213
+ /**
214
+ * Read all known scd repo paths from ~/.scd/repos/{repoId}/meta.json.
215
+ * Returns array of { repoId, name, localPath } for repos with a known localPath.
216
+ */
217
+ function getKnownRepoPaths() {
218
+ try {
219
+ const os = require('os');
220
+ const reposDir = path.join(os.homedir(), '.scd', 'repos');
221
+ if (!fs.existsSync(reposDir)) return [];
222
+
223
+ return fs.readdirSync(reposDir)
224
+ .map(id => {
225
+ try {
226
+ const meta = JSON.parse(fs.readFileSync(path.join(reposDir, id, 'meta.json'), 'utf8'));
227
+ return meta.localPath ? { repoId: id, name: meta.name || id, localPath: meta.localPath } : null;
228
+ } catch { return null; }
229
+ })
230
+ .filter(Boolean);
231
+ } catch { return []; }
232
+ }
233
+
234
+ /**
235
+ * Check if repoRoot overlaps with any known scd repo (parent or child).
236
+ * Returns array of overlapping repos: { repoId, name, localPath, relation }
237
+ * where relation is 'parent' or 'child'.
238
+ */
239
+ function findOverlappingRepos(repoRoot, excludeRepoId = null) {
240
+ const known = getKnownRepoPaths();
241
+ const absRoot = path.resolve(repoRoot);
242
+ const sep = path.sep;
243
+
244
+ return known
245
+ .filter(r => r.repoId !== excludeRepoId)
246
+ .filter(r => {
247
+ const absKnown = path.resolve(r.localPath);
248
+ if (absKnown === absRoot) return false; // same repo — skip
249
+
250
+ const rootWithSep = absRoot.endsWith(sep) ? absRoot : absRoot + sep;
251
+ const knownWithSep = absKnown.endsWith(sep) ? absKnown : absKnown + sep;
252
+
253
+ const isChild = absKnown.startsWith(rootWithSep); // known is inside current
254
+ const isParent = absRoot.startsWith(knownWithSep); // current is inside known
255
+
256
+ if (isChild) { r.relation = 'child'; return true; }
257
+ if (isParent) { r.relation = 'parent'; return true; }
258
+ return false;
259
+ });
260
+ }
261
+
262
+ /**
263
+ * Warn the user if the resolved repoRoot overlaps with another known scd repo.
264
+ * Returns { proceed: bool, skipLogging: bool, cancelled: bool }.
265
+ *
266
+ * For hook mode (interactive=false): warn but always proceed — hooks can't prompt.
267
+ * For non-interactive mode (no TTY): warn but always proceed — same as hook mode.
268
+ */
269
+ async function checkRepoOverlap(repoRoot, { interactive = true, repoId = null } = {}) {
270
+ const overlaps = findOverlappingRepos(repoRoot, repoId);
271
+ if (overlaps.length === 0) return { proceed: true, skipLogging: false, cancelled: false };
272
+
273
+ const children = overlaps.filter(r => r.relation === 'child');
274
+ const parents = overlaps.filter(r => r.relation === 'parent');
275
+
276
+ console.log(`\n${YELLOW}⚠️ This repo overlaps with another scd repository.${RESET}`);
277
+ console.log(`${DIM} Current scan: ${repoRoot}${RESET}`);
278
+
279
+ for (const r of parents) console.log(`${DIM} Parent repo: ${r.localPath} (${r.name})${RESET}`);
280
+ for (const r of children) console.log(`${DIM} Child repo: ${r.localPath} (${r.name})${RESET}`);
281
+
282
+ if (children.length > 0) {
283
+ console.log(`\n${DIM} Scanning from here will include files already tracked in the child repo,${RESET}`);
284
+ console.log(`${DIM} potentially duplicating findings in scd-server.${RESET}`);
285
+ }
286
+ if (parents.length > 0) {
287
+ console.log(`\n${DIM} This repo is nested inside another scd repo — findings may appear twice.${RESET}`);
288
+ }
289
+
290
+ // Hook mode or no TTY — can't prompt, warn and proceed
291
+ if (!interactive || !process.stdin.isTTY) {
292
+ console.log(`${YELLOW} Continuing scan (non-interactive — cannot prompt).${RESET}\n`);
293
+ return { proceed: true, skipLogging: false, cancelled: false };
294
+ }
295
+
296
+ console.log(`\n How would you like to proceed?\n`);
297
+ console.log(` ${CYAN}[1]${RESET} Continue anyway`);
298
+ console.log(` ${CYAN}[2]${RESET} Scan without logging ${DIM}(results shown only, nothing saved)${RESET}`);
299
+ console.log(` ${CYAN}[3]${RESET} Cancel`);
300
+
301
+ const choice = (await prompt(`\n Choice [1]: `)).trim() || '1';
302
+
303
+ if (choice === '3' || choice.toLowerCase() === 'cancel') {
304
+ return { proceed: false, skipLogging: true, cancelled: true };
305
+ }
306
+ if (choice === '2') {
307
+ return { proceed: true, skipLogging: true, cancelled: false };
308
+ }
309
+ return { proceed: true, skipLogging: false, cancelled: false };
310
+ }
311
+
312
+ module.exports = { resolveTargetContext, findGitRoot, checkRepoOverlap, findOverlappingRepos };