@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,322 @@
1
+ /**
2
+ * push-queue.js
3
+ * Offline-first push queue for scd events → scd-server.
4
+ *
5
+ * Events are always written to audit.log first (existing behaviour).
6
+ * When a central URL is configured, events are also queued here and
7
+ * flushed to the server on every scd command (non-blocking).
8
+ *
9
+ * Queue file: ~/.scd/push-queue.jsonl (one JSON object per line)
10
+ *
11
+ * Each entry:
12
+ * { id, ts, attempts, lastAttempt, event: { ...audit event } }
13
+ *
14
+ * Flush behaviour:
15
+ * - Sends all pending events as a single POST /api/v1/events/batch
16
+ * - On success: removes sent entries from queue
17
+ * - On failure: increments attempts, updates lastAttempt, keeps entry
18
+ * - Silent on network errors – never blocks the CLI
19
+ *
20
+ * Stale threshold: entries with attempts >= 10 are considered stale.
21
+ * scd doctor reports stale count; scd repo --verify --clean can purge.
22
+ * TODO: scd store/repo --verify --clean is currently per-repo but the push queue
23
+ * lives globally at ~/.scd — consider a global scd verify --clean command.
24
+ *
25
+ * Grace period: if all events are older than 7 days the user is warned
26
+ * by scd doctor (not by the push worker itself).
27
+ */
28
+
29
+ 'use strict';
30
+ const { DIM } = require('./output-constants');
31
+
32
+ const fs = require('fs');
33
+ const path = require('path');
34
+ const os = require('os');
35
+ const crypto = require('crypto');
36
+
37
+ const QUEUE_PATH = path.join(os.homedir(), '.scd', 'push-queue.jsonl');
38
+ // Stale threshold: entries older than STALE_DAYS with at least one permanent failure
39
+ // are considered stale. Transient failures (network down, server unreachable) do NOT
40
+ // increment attempts and never cause entries to go stale.
41
+ const STALE_DAYS = 30;
42
+ const STALE_ATTEMPTS = 10; // kept for backwards compat with existing queue entries
43
+ const GRACE_DAYS = 7;
44
+ const BATCH_ENDPOINT = '/api/v1/events/batch';
45
+
46
+ // ── Machine fingerprint ──────────────────────────────────────────────────
47
+ const { getMachineFingerprint } = require('./store');
48
+
49
+ /**
50
+ * Build the meta object sent alongside events.
51
+ * Includes installation identity and repo context.
52
+ * repoRoot is optional — omitted when flush is called outside a repo context.
53
+ */
54
+ function buildMeta(repoRoot) {
55
+ const meta = {
56
+ installationId: getMachineFingerprint(),
57
+ hostname: os.hostname(),
58
+ platform: os.platform(),
59
+ scdVersion: (() => {
60
+ try { return require('../package.json').version; } catch { return null; }
61
+ })(),
62
+ };
63
+
64
+ if (repoRoot) {
65
+ try {
66
+ const store = require('./store');
67
+ meta.repoId = store.getRepoId(repoRoot);
68
+ meta.repoName = path.basename(path.resolve(repoRoot));
69
+ // Include remote URL if available
70
+ const identity = store.getRepoIdentity ? store.getRepoIdentity(repoRoot) : null;
71
+ if (identity && identity.type === 'remote') {
72
+ meta.repoRemote = identity.identifier;
73
+ }
74
+ } catch { /* non-fatal */ }
75
+ }
76
+
77
+ return meta;
78
+ }
79
+
80
+ // ── Queue file helpers ────────────────────────────────────────────────────
81
+
82
+ function readQueue() {
83
+ if (!fs.existsSync(QUEUE_PATH)) return [];
84
+ try {
85
+ return fs.readFileSync(QUEUE_PATH, 'utf8')
86
+ .split('\n')
87
+ .filter(Boolean)
88
+ .map(line => JSON.parse(line));
89
+ } catch {
90
+ return [];
91
+ }
92
+ }
93
+
94
+ function writeQueue(entries) {
95
+ try {
96
+ const dir = path.dirname(QUEUE_PATH);
97
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
98
+ const content = entries.map(e => JSON.stringify(e)).join('\n');
99
+ fs.writeFileSync(QUEUE_PATH, content ? content + '\n' : '', { encoding: 'utf8', mode: 0o600 });
100
+ } catch {
101
+ // Queue write failure is non-fatal — audit.log is the source of truth
102
+ }
103
+ }
104
+
105
+ // ── Public API ────────────────────────────────────────────────────────────
106
+
107
+ /**
108
+ * Add an audit event to the push queue.
109
+ * Called from audit.js whenever a scan completes.
110
+ */
111
+ function enqueue(auditEvent) {
112
+ try {
113
+ const entry = {
114
+ id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
115
+ ts: new Date().toISOString(),
116
+ attempts: 0,
117
+ lastAttempt: null,
118
+ event: auditEvent,
119
+ };
120
+ const dir = path.dirname(QUEUE_PATH);
121
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
122
+ fs.appendFileSync(QUEUE_PATH, JSON.stringify(entry) + '\n', { encoding: 'utf8', mode: 0o600 });
123
+ } catch {
124
+ // Non-fatal
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Flush pending events to scd-server.
130
+ * Returns a status string for optional display: 'sent', 'unreachable', 'empty', 'error'.
131
+ * Always resolves — never rejects.
132
+ *
133
+ * @param {string} centralUrl e.g. 'https://security.company.internal:3000'
134
+ * @param {object} opts
135
+ * @param {boolean} opts.verbose print result to terminal
136
+ */
137
+ async function flush(centralUrl, opts = {}) {
138
+ const entries = readQueue();
139
+ const pending = entries.filter(e => !isStale(e));
140
+
141
+ if (pending.length === 0) return 'empty';
142
+
143
+ const url = centralUrl.replace(/\/$/, '') + BATCH_ENDPOINT;
144
+ const now = new Date().toISOString();
145
+ const token = opts.token || (() => {
146
+ try { return require('./global-config').getCentralToken(); } catch { return null; }
147
+ })();
148
+
149
+ try {
150
+ const http = url.startsWith('https') ? require('https') : require('http');
151
+ const meta = buildMeta(opts.repoRoot || null);
152
+ const body = JSON.stringify({ events: pending.map(e => e.event), meta });
153
+
154
+ const response = await httpPost(http, url, body, token);
155
+
156
+ if (response.status >= 200 && response.status < 300) {
157
+ // Remove successfully sent entries, keep stale ones
158
+ const sentIds = new Set(pending.map(e => e.id));
159
+ const remaining = entries.filter(e => !sentIds.has(e.id));
160
+ writeQueue(remaining);
161
+
162
+ // Cache server version info for CLI version warning
163
+ try {
164
+ const parsed = JSON.parse(response.body);
165
+ if (parsed.server_version || parsed.min_cli_version) {
166
+ require('./global-config').setServerVersionInfo(
167
+ parsed.server_version || null,
168
+ parsed.min_cli_version || null
169
+ );
170
+ }
171
+ } catch { /* non-fatal */ }
172
+
173
+ if (opts.verbose) {
174
+ console.log(`${DIM} ↑ Synced ${pending.length} queued event(s) to central${RESET}`);
175
+ }
176
+ return 'sent';
177
+ } else if (response.status === 503) {
178
+ // Server license invalid — do NOT bump attempts, keep events intact for retry
179
+ // Data is safe in queue and will sync automatically when license is restored
180
+ let isLicenseInvalid = false;
181
+ try {
182
+ const parsed = JSON.parse(response.body);
183
+ isLicenseInvalid = parsed.error === 'License invalid';
184
+ } catch { /* ignore parse error */ }
185
+
186
+ if (isLicenseInvalid) {
187
+ if (opts.verbose) {
188
+ console.log(`${DIM} ↑ Server license invalid – ${pending.length} event(s) held in queue${RESET}`);
189
+ }
190
+ return 'license_invalid';
191
+ }
192
+ // Other 503 — server temporarily unavailable, do NOT bump attempts
193
+ if (opts.verbose) {
194
+ console.log(`${DIM} ↑ Central unavailable (503) – ${pending.length} event(s) held in queue${RESET}`);
195
+ }
196
+ return 'unreachable';
197
+ } else {
198
+ // Server reachable but returned error — increment attempts
199
+ bumpAttempts(entries, pending, now);
200
+ if (opts.verbose) {
201
+ console.log(`${DIM} ↑ Central returned ${response.status} – ${pending.length} event(s) queued for next sync${RESET}`);
202
+ }
203
+ return 'error';
204
+ }
205
+ } catch {
206
+ // Network error — server unreachable (connection refused, DNS failure, timeout)
207
+ // Do NOT bump attempts — this is a transient failure, not a permanent error.
208
+ // Events stay in queue and will sync when server comes back online.
209
+ if (opts.verbose) {
210
+ console.log(`${DIM} ↑ Central unreachable – ${pending.length} event(s) held in queue${RESET}`);
211
+ }
212
+ return 'unreachable';
213
+ }
214
+ }
215
+
216
+ function bumpAttempts(allEntries, pendingEntries, now) {
217
+ const pendingIds = new Set(pendingEntries.map(e => e.id));
218
+ const updated = allEntries.map(e => {
219
+ if (!pendingIds.has(e.id)) return e;
220
+ return { ...e, attempts: e.attempts + 1, lastAttempt: now };
221
+ });
222
+ writeQueue(updated);
223
+ }
224
+
225
+ /**
226
+ * Determine if a queue entry is stale.
227
+ * An entry is stale if:
228
+ * - It has >= STALE_ATTEMPTS permanent failures (backwards compat), OR
229
+ * - It is older than STALE_DAYS days
230
+ * Transient failures (unreachable) do not increment attempts and are never stale
231
+ * unless they are very old.
232
+ */
233
+ function isStale(entry) {
234
+ if (entry.attempts >= STALE_ATTEMPTS) return true;
235
+ const ageMs = Date.now() - new Date(entry.ts).getTime();
236
+ return ageMs > STALE_DAYS * 24 * 60 * 60 * 1000;
237
+ }
238
+
239
+ /**
240
+ * Number of events currently in queue (excluding stale).
241
+ */
242
+ function queueSize() {
243
+ return readQueue().filter(e => !isStale(e)).length;
244
+ }
245
+
246
+ /**
247
+ * Number of stale events (attempts >= STALE_ATTEMPTS).
248
+ */
249
+ function staleCount() {
250
+ return readQueue().filter(e => isStale(e)).length;
251
+ }
252
+
253
+ /**
254
+ * Whether all pending events are older than the grace period.
255
+ * Returns false if queue is empty.
256
+ */
257
+ function isPastGrace() {
258
+ const pending = readQueue().filter(e => e.attempts < STALE_ATTEMPTS);
259
+ if (pending.length === 0) return false;
260
+ const oldest = pending.reduce((min, e) => e.ts < min ? e.ts : min, pending[0].ts);
261
+ const ageMs = Date.now() - new Date(oldest).getTime();
262
+ return ageMs > GRACE_DAYS * 24 * 60 * 60 * 1000;
263
+ }
264
+
265
+ /**
266
+ * Remove all stale entries from queue.
267
+ * Returns number of entries removed.
268
+ */
269
+ function purgeStale() {
270
+ const entries = readQueue();
271
+ const clean = entries.filter(e => !isStale(e));
272
+ writeQueue(clean);
273
+ return entries.length - clean.length;
274
+ }
275
+
276
+ // ── HTTP helper (no external deps) ───────────────────────────────────────
277
+
278
+ function httpPost(http, urlStr, body, token = null) {
279
+ return new Promise((resolve, reject) => {
280
+ const u = new URL(urlStr);
281
+ const headers = {
282
+ 'Content-Type': 'application/json',
283
+ 'Content-Length': Buffer.byteLength(body),
284
+ 'User-Agent': 'scd-cli/1',
285
+ };
286
+ if (token) headers['Authorization'] = `Bearer ${token}`;
287
+ const { getServerTimeout } = require('./global-config');
288
+ const options = {
289
+ hostname: u.hostname,
290
+ port: u.port || (u.protocol === 'https:' ? 443 : 80),
291
+ path: u.pathname + u.search,
292
+ method: 'POST',
293
+ headers,
294
+ timeout: getServerTimeout(),
295
+ };
296
+
297
+ const req = http.request(options, (res) => {
298
+ let data = '';
299
+ res.on('data', chunk => { data += chunk; });
300
+ res.on('end', () => resolve({ status: res.statusCode, body: data }));
301
+ });
302
+
303
+ req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
304
+ req.on('error', reject);
305
+ req.write(body);
306
+ req.end();
307
+ });
308
+ }
309
+
310
+ module.exports = {
311
+ enqueue,
312
+ flush,
313
+ queueSize,
314
+ staleCount,
315
+ isPastGrace,
316
+ purgeStale,
317
+ getMachineFingerprint,
318
+ buildMeta,
319
+ QUEUE_PATH,
320
+ STALE_ATTEMPTS,
321
+ GRACE_DAYS,
322
+ };
@@ -0,0 +1,108 @@
1
+ /**
2
+ * remove-repo.js
3
+ * Removes scd integration from the current repo.
4
+ *
5
+ * Hooks are global (shared across all repos via core.hooksPath) so they
6
+ * are NOT removed — removing them would break all other repos on this machine.
7
+ * Instead, the repo is marked as inactive in the store.
8
+ *
9
+ * Scan history in ~/.scd/repos/{repoId}/ is kept by default.
10
+ * The user must explicitly type "yes" to delete it.
11
+ */
12
+
13
+ 'use strict';
14
+ const { RESET, BOLD, DIM, RED, GREEN, YELLOW, CYAN } = require('./output-constants');
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+
19
+ async function removeRepo(repoRoot) {
20
+ const store = require('./store');
21
+
22
+
23
+ const repoId = store.getRepoId(repoRoot);
24
+ const storePath = store.storeDir(repoRoot);
25
+ const meta = store.readMeta(repoRoot);
26
+ const repoName = meta?.name || path.basename(repoRoot);
27
+
28
+ console.log('\n' + BOLD + 'scd remove' + RESET);
29
+ console.log(DIM + ' Repository: ' + repoName + RESET);
30
+ console.log(DIM + ' Store ID: ' + repoId + RESET);
31
+ console.log(DIM + ' Path: ' + repoRoot + RESET);
32
+ console.log('');
33
+
34
+ // Disable hooks for this repo on removal — repo is no longer under scd management.
35
+ // Sets local core.hooksPath to /dev/null so git hooks stop triggering,
36
+ // regardless of global config or any previous local override.
37
+ const { disableHooks } = require('./hooks-manager');
38
+ try {
39
+ disableHooks(repoRoot);
40
+ console.log(GREEN + ' ✓ Git hooks disabled for this repo' + RESET);
41
+ console.log(DIM + ' Run scd init to re-enable scanning and hooks' + RESET);
42
+ } catch {
43
+ console.log(DIM + ' Note: Could not disable hooks — run manually:' + RESET);
44
+ const nullDev = process.platform === 'win32' ? 'NUL' : '/dev/null';
45
+ console.log(DIM + ' git config --local core.hooksPath ' + nullDev + RESET);
46
+ }
47
+ console.log('');
48
+
49
+ // Mark as inactive in store
50
+ try {
51
+ const metaPath = path.join(storePath, 'meta.json');
52
+ let existing = {};
53
+ try { existing = JSON.parse(fs.readFileSync(metaPath, 'utf8')); } catch {}
54
+ existing.removed = true;
55
+ existing.removedAt = new Date().toISOString();
56
+ fs.writeFileSync(metaPath, JSON.stringify(existing, null, 2), 'utf8');
57
+ console.log(GREEN + ' ✓ Repository marked as inactive' + RESET);
58
+ } catch (err) {
59
+ console.log(YELLOW + ' ⚠ Could not update store: ' + err.message + RESET);
60
+ }
61
+
62
+ // Ask about scan history
63
+ console.log('');
64
+ console.log(' Scan history is stored in:');
65
+ console.log(DIM + ' ' + storePath + RESET);
66
+ console.log('');
67
+ console.log(DIM + ' Keeping history lets you run ' + CYAN + 'scd init' + DIM + ' again and continue' + RESET);
68
+ console.log(DIM + ' from where you left off. You can also clean up later with:' + RESET);
69
+ console.log(DIM + ' scd repo --verify --clean' + RESET);
70
+ console.log('');
71
+
72
+ const answer = await promptLine(
73
+ YELLOW + ' Delete scan history? Type "yes" to confirm, or press Enter to keep: ' + RESET
74
+ );
75
+
76
+ if (answer.trim().toLowerCase() === 'yes') {
77
+ try {
78
+ fs.rmSync(storePath, { recursive: true, force: true });
79
+ console.log('');
80
+ console.log(RED + ' ✓ Scan history deleted' + RESET);
81
+ } catch (err) {
82
+ console.log(RED + ' ✗ Could not delete history: ' + err.message + RESET);
83
+ }
84
+ } else {
85
+ console.log('');
86
+ console.log(DIM + ' ✓ Scan history kept' + RESET);
87
+ }
88
+
89
+ console.log('');
90
+ console.log(GREEN + BOLD + ' Done.' + RESET);
91
+ console.log(DIM + ' Run ' + CYAN + 'scd init' + DIM + ' to re-register this repo.' + RESET);
92
+ console.log(DIM + ' Run ' + CYAN + 'scd repo --verify --clean' + DIM + ' to manage inactive repos.' + RESET);
93
+ console.log('');
94
+ }
95
+
96
+ function promptLine(question) {
97
+ return new Promise(resolve => {
98
+ process.stdout.write(question);
99
+ process.stdin.setEncoding('utf8');
100
+ process.stdin.resume();
101
+ process.stdin.once('data', data => {
102
+ process.stdin.pause();
103
+ resolve(data.toString().trim());
104
+ });
105
+ });
106
+ }
107
+
108
+ module.exports = { removeRepo };
@@ -0,0 +1,187 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * lib/repo-context.js
5
+ *
6
+ * Reads project manifest files to build a repo context snapshot.
7
+ * Context is used locally for smarter rule targeting and sent to
8
+ * scd-server for dependency tracking, compliance reports, and
9
+ * future intel (CVE matching) features.
10
+ *
11
+ * Supported manifests:
12
+ * JavaScript/TypeScript: package.json
13
+ * Python: requirements.txt, pyproject.toml
14
+ * PHP: composer.json
15
+ * .NET: *.csproj
16
+ *
17
+ * Storage: ~/.scd/repos/{repoId}/repo-context.json
18
+ * Updated: at each scan, only if content changed.
19
+ * Server: sent as repo_context event via push-queue after scan.
20
+ */
21
+
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+
25
+ function parsePackageJson(filePath) {
26
+ try {
27
+ const pkg = JSON.parse(fs.readFileSync(filePath, 'utf8'));
28
+ const deps = {};
29
+ for (const [n, v] of Object.entries(pkg.dependencies || {})) deps[n] = { version: v, dev: false };
30
+ for (const [n, v] of Object.entries(pkg.devDependencies || {})) deps[n] = { version: v, dev: true };
31
+
32
+ const keys = Object.keys(pkg.dependencies || {});
33
+ const frameworks = [];
34
+ if (keys.includes('express')) frameworks.push('express');
35
+ if (keys.includes('fastify')) frameworks.push('fastify');
36
+ if (keys.includes('koa')) frameworks.push('koa');
37
+ if (keys.includes('next')) frameworks.push('next');
38
+ if (keys.includes('react')) frameworks.push('react');
39
+ if (keys.includes('vue')) frameworks.push('vue');
40
+ if (keys.some(k => k.startsWith('@nestjs'))) frameworks.push('nestjs');
41
+ if (keys.includes('typeorm')) frameworks.push('typeorm');
42
+ if (keys.includes('sequelize')) frameworks.push('sequelize');
43
+ if (keys.includes('mongoose')) frameworks.push('mongoose');
44
+ if (keys.includes('jsonwebtoken')) frameworks.push('jsonwebtoken');
45
+
46
+ return { language: 'javascript', manifest: 'package.json',
47
+ name: pkg.name || null, version: pkg.version || null, frameworks, dependencies: deps };
48
+ } catch { return null; }
49
+ }
50
+
51
+ function parseRequirementsTxt(filePath) {
52
+ try {
53
+ const deps = {};
54
+ for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
55
+ const t = line.trim();
56
+ if (!t || t.startsWith('#') || t.startsWith('-')) continue;
57
+ const m = t.match(/^([A-Za-z0-9_.-]+)\s*([><=!~]+\s*[\d.*]+)?/);
58
+ if (m) deps[m[1].toLowerCase()] = { version: m[2]?.trim() || '*', dev: false };
59
+ }
60
+ const keys = Object.keys(deps);
61
+ const frameworks = [];
62
+ if (keys.includes('flask')) frameworks.push('flask');
63
+ if (keys.includes('django')) frameworks.push('django');
64
+ if (keys.includes('fastapi')) frameworks.push('fastapi');
65
+ if (keys.includes('sqlalchemy')) frameworks.push('sqlalchemy');
66
+ if (keys.includes('pyjwt')) frameworks.push('pyjwt');
67
+ return { language: 'python', manifest: 'requirements.txt', frameworks, dependencies: deps };
68
+ } catch { return null; }
69
+ }
70
+
71
+ function parsePyprojectToml(filePath) {
72
+ try {
73
+ const content = fs.readFileSync(filePath, 'utf8');
74
+ const deps = {};
75
+ const sec = content.match(/\[(?:project\.dependencies|tool\.poetry\.dependencies)\]([\s\S]*?)(?=\[|$)/);
76
+ if (sec) {
77
+ for (const line of sec[1].split('\n')) {
78
+ const m = line.match(/^([A-Za-z0-9_.-]+)\s*=\s*["']?([^"'\n]+)["']?/);
79
+ if (m) deps[m[1].toLowerCase()] = { version: m[2].trim(), dev: false };
80
+ const am = line.match(/["']([A-Za-z0-9_.-]+)\s*([><=!~]+\s*[\d.*]+)?["']/);
81
+ if (am && !m) deps[am[1].toLowerCase()] = { version: am[2]?.trim() || '*', dev: false };
82
+ }
83
+ }
84
+ if (!Object.keys(deps).length) return null;
85
+ const keys = Object.keys(deps);
86
+ const frameworks = ['flask','django','fastapi','sqlalchemy','pyjwt'].filter(f => keys.includes(f));
87
+ return { language: 'python', manifest: 'pyproject.toml', frameworks, dependencies: deps };
88
+ } catch { return null; }
89
+ }
90
+
91
+ function parseComposerJson(filePath) {
92
+ try {
93
+ const pkg = JSON.parse(fs.readFileSync(filePath, 'utf8'));
94
+ const deps = {};
95
+ for (const [n, v] of Object.entries(pkg.require || {})) { if (n !== 'php') deps[n] = { version: v, dev: false }; }
96
+ for (const [n, v] of Object.entries(pkg['require-dev'] || {})) deps[n] = { version: v, dev: true };
97
+ const keys = Object.keys(deps);
98
+ const frameworks = [];
99
+ if (keys.some(k => k.startsWith('laravel/'))) frameworks.push('laravel');
100
+ if (keys.some(k => k.startsWith('symfony/'))) frameworks.push('symfony');
101
+ if (keys.includes('slim/slim')) frameworks.push('slim');
102
+ if (keys.includes('doctrine/orm')) frameworks.push('doctrine');
103
+ return { language: 'php', manifest: 'composer.json', name: pkg.name || null, frameworks, dependencies: deps };
104
+ } catch { return null; }
105
+ }
106
+
107
+ function parseCsproj(filePath) {
108
+ try {
109
+ const content = fs.readFileSync(filePath, 'utf8');
110
+ const deps = {};
111
+ for (const [, n, v] of content.matchAll(/<PackageReference\s+Include="([^"]+)"\s+Version="([^"]+)"/gi))
112
+ deps[n.toLowerCase()] = { version: v, dev: false };
113
+ if (!Object.keys(deps).length) return null;
114
+ const keys = Object.keys(deps);
115
+ const frameworks = [];
116
+ if (keys.some(k => k.includes('aspnetcore'))) frameworks.push('aspnetcore');
117
+ if (keys.some(k => k.includes('entityframework'))) frameworks.push('entityframework');
118
+ const tfm = content.match(/<TargetFramework[s]?>([^<]+)<\/TargetFramework[s]?>/i);
119
+ return { language: 'csharp', manifest: path.basename(filePath),
120
+ targetFramework: tfm ? tfm[1].trim() : null, frameworks, dependencies: deps };
121
+ } catch { return null; }
122
+ }
123
+
124
+ function parseRepoContext(repoRoot) {
125
+ const results = [];
126
+ function add(r) { if (r) results.push(r); }
127
+
128
+ add(parsePackageJson(path.join(repoRoot, 'package.json')));
129
+ const hasReqTxt = fs.existsSync(path.join(repoRoot, 'requirements.txt'));
130
+ if (hasReqTxt) add(parseRequirementsTxt(path.join(repoRoot, 'requirements.txt')));
131
+ else add(parsePyprojectToml(path.join(repoRoot, 'pyproject.toml')));
132
+ add(parseComposerJson(path.join(repoRoot, 'composer.json')));
133
+
134
+ try {
135
+ const csproj = fs.readdirSync(repoRoot).find(e => e.endsWith('.csproj'));
136
+ if (csproj) add(parseCsproj(path.join(repoRoot, csproj)));
137
+ } catch { /* ignore */ }
138
+
139
+ if (!results.length) return null;
140
+
141
+ const languages = [...new Set(results.map(r => r.language))];
142
+ const frameworks = [...new Set(results.flatMap(r => r.frameworks || []))];
143
+ const manifests = results.map(r => r.manifest);
144
+
145
+ const dependencies = {};
146
+ for (const r of results)
147
+ for (const [n, info] of Object.entries(r.dependencies || {}))
148
+ dependencies[`${r.language}:${n}`] = { ...info, language: r.language };
149
+
150
+ return {
151
+ scannedAt: new Date().toISOString(),
152
+ languages,
153
+ frameworks,
154
+ manifestFiles: manifests,
155
+ manifests: results.map(r => ({
156
+ language: r.language,
157
+ manifest: r.manifest,
158
+ name: r.name || null,
159
+ version: r.version || null,
160
+ frameworks: r.frameworks || [],
161
+ targetFramework: r.targetFramework || null,
162
+ dependencyCount: Object.keys(r.dependencies || {}).length,
163
+ })),
164
+ dependencies,
165
+ };
166
+ }
167
+
168
+ function loadRepoContext(repoRoot) {
169
+ try {
170
+ const { storeDir } = require('./store');
171
+ const p = path.join(storeDir(repoRoot), 'repo-context.json');
172
+ return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf8')) : null;
173
+ } catch { return null; }
174
+ }
175
+
176
+ function saveRepoContext(repoRoot, context) {
177
+ try {
178
+ const { storeDir } = require('./store');
179
+ const p = path.join(storeDir(repoRoot), 'repo-context.json');
180
+ const existing = loadRepoContext(repoRoot);
181
+ const sig = c => JSON.stringify({ languages: c.languages, frameworks: c.frameworks, dependencies: c.dependencies });
182
+ fs.writeFileSync(p, JSON.stringify(context, null, 2), 'utf8');
183
+ return !existing || sig(existing) !== sig(context);
184
+ } catch { return false; }
185
+ }
186
+
187
+ module.exports = { parseRepoContext, loadRepoContext, saveRepoContext };