forgedock 1.1.7 → 1.1.8

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 (37) hide show
  1. package/bin/engine/invariants.mjs +361 -0
  2. package/bin/hooks/pre-tool-use.mjs +182 -0
  3. package/bin/labels.json +10 -0
  4. package/bin/recall.mjs +540 -0
  5. package/commands/autopilot.md +957 -76
  6. package/commands/cleanup.md +52 -3
  7. package/commands/optimize.md +585 -0
  8. package/commands/orchestrate/phase-1-resolve.md +35 -25
  9. package/commands/orchestrate/phase-3-dependency.md +465 -34
  10. package/commands/orchestrate/phase-4-execution.md +447 -28
  11. package/commands/orchestrate/phase-6-report.md +198 -9
  12. package/commands/pipeline-health/phase-2-metrics.md +305 -1
  13. package/commands/pipeline-health/phase-4-proposals.md +127 -0
  14. package/commands/quality-gate.md +417 -0
  15. package/commands/review-pr.md +391 -13
  16. package/commands/spec-doctor.md +429 -0
  17. package/commands/validate.md +52 -0
  18. package/commands/work-on/build/architect.md +77 -0
  19. package/commands/work-on/build/context.md +357 -22
  20. package/commands/work-on/build/implement.md +2 -0
  21. package/commands/work-on/build/validate.md +28 -0
  22. package/commands/work-on/build.md +44 -0
  23. package/commands/work-on/close.md +621 -8
  24. package/commands/work-on/investigate.md +221 -0
  25. package/commands/work-on.md +15 -14
  26. package/docs/FORGE-PROTOCOL.md +101 -0
  27. package/docs/spec/forge-protocol-v1.md +97 -5
  28. package/docs/spec/label-state-machine.md +6 -1
  29. package/package.json +2 -2
  30. package/packages/protocol/src/cli.js +374 -84
  31. package/packages/protocol/src/types.js +75 -1
  32. package/scripts/build-knowledge-index.mjs +1761 -0
  33. package/scripts/calibration.mjs +996 -0
  34. package/scripts/danger-zones.mjs +782 -0
  35. package/scripts/doctor-pipeline-state.sh +88 -2
  36. package/scripts/select-fix-targets.sh +234 -0
  37. package/scripts/transition-label.sh +157 -6
@@ -0,0 +1,782 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ // SPDX-License-Identifier: AGPL-3.0-or-later
4
+
5
+ /**
6
+ * danger-zones.mjs — Forge Ledger danger-zone + co-change matrix builder.
7
+ *
8
+ * Reads ~/.forge/index/knowledge.jsonl (produced by build-knowledge-index.mjs),
9
+ * aggregates per-file finding counts over a 90-day window, then mines git commit
10
+ * history for co-change statistics using an adjacency JSONL format with a
11
+ * monthly ring buffer.
12
+ *
13
+ * Outputs (written to the same index directory as the Forge Ledger):
14
+ * - ~/.forge/index/danger-zones.json — per-file risk summary
15
+ * - ~/.forge/index/cochange.jsonl — co-change adjacency matrix
16
+ *
17
+ * Storage format (per FORGE:DESIGN_DECISION on issue #1738):
18
+ * Adjacency JSONL — one line per file, lexicographic key order, partners as map
19
+ * with monthly ring-buffer counts [month-2, month-1, month-0]:
20
+ * {"file":"bin/runner.mjs","n":[3,7,6],"partners":{"bin/engine.mjs":[1,3,2]}}
21
+ *
22
+ * Normalization (per FORGE:DESIGN_DECISION addendum on issue #1738):
23
+ * Neither file ubiquitous (n/N <= 0.2 both):
24
+ * couple iff c >= 3 and c/min(n(a),n(b)) >= 0.5
25
+ * Exactly one ubiquitous (n/N > 0.2):
26
+ * couple iff c >= 3 and c/n(quiet_side) >= 0.75 [directional confidence]
27
+ * Both ubiquitous:
28
+ * couple iff c >= 3 and c/n(a) >= 0.75 and c/n(b) >= 0.75
29
+ *
30
+ * Ubiquitous pairs are INELIGIBLE for verified-independent downgrade.
31
+ *
32
+ * Cold-start safety: matrix MUST NOT downgrade edges for files with n < 5 in window.
33
+ *
34
+ * Declared companions (forge.yaml → companions) are axiomatic edges (confidence 1.0).
35
+ *
36
+ * Usage:
37
+ * node scripts/danger-zones.mjs [options]
38
+ *
39
+ * Options:
40
+ * --repo-path <path> Path to git repo (default: cwd or git root)
41
+ * --output <dir> Override index directory (default: ~/.forge/index)
42
+ * --incremental Only process new commits since last run (default)
43
+ * --full-rebuild Ignore watermark; rebuild entire matrix from 90d history
44
+ * --query <file> Query mode: print danger-zone entry for a single file and exit
45
+ * --verbose Verbose logging
46
+ * --dry-run Parse and print without writing
47
+ *
48
+ * Exit codes: 0 = success, 1 = fatal error
49
+ *
50
+ * @module danger-zones
51
+ */
52
+
53
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
54
+ import { execSync, spawnSync } from 'child_process';
55
+ import { homedir } from 'os';
56
+ import { dirname, join, relative, resolve } from 'path';
57
+ import { fileURLToPath } from 'url';
58
+
59
+ const __filename = fileURLToPath(import.meta.url);
60
+ const __dirname = dirname(__filename);
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Constants
64
+ // ---------------------------------------------------------------------------
65
+
66
+ const DEFAULT_INDEX_DIR = join(homedir(), '.forge', 'index');
67
+ const CARDS_FILE = 'knowledge.jsonl';
68
+ const DANGER_ZONES_FILE = 'danger-zones.json';
69
+ const COCHANGE_FILE = 'cochange.jsonl';
70
+ const COCHANGE_META_FILE = 'cochange-meta.json';
71
+
72
+ // 90-day window = 3 monthly buckets
73
+ const WINDOW_MONTHS = 3;
74
+ const WINDOW_DAYS = 90;
75
+
76
+ // Coupling thresholds (per FORGE:DESIGN_DECISION on #1738)
77
+ const SUPPORT_THRESHOLD = 3; // c(a,b) >= 3
78
+ const CONFIDENCE_STANDARD = 0.5; // c/min(n(a),n(b)) >= 0.5 (neither ubiquitous)
79
+ const CONFIDENCE_DIRECTIONAL = 0.75; // c/n(quiet_side) >= 0.75 (one ubiquitous)
80
+ const CONFIDENCE_MUTUAL = 0.75; // c/n >= 0.75 BOTH sides (both ubiquitous)
81
+ const UBIQUITY_THRESHOLD = 0.2; // n/N > 0.2 → ubiquitous
82
+
83
+ // Cold-start: matrix lookups only authoritative for files with n >= MIN_CHANGE_COUNT
84
+ const MIN_CHANGE_COUNT = 5;
85
+
86
+ // Finding-count threshold for danger-zone classification
87
+ const FINDING_HOT_THRESHOLD = 3; // >= 3 findings in 90d → HOT-FINDINGS
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Logging
91
+ // ---------------------------------------------------------------------------
92
+
93
+ function log(msg) {
94
+ process.stderr.write(`[danger-zones] ${msg}\n`);
95
+ }
96
+
97
+ function debug(msg) {
98
+ if (globalThis._VERBOSE) {
99
+ process.stderr.write(`[danger-zones:debug] ${msg}\n`);
100
+ }
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Argument parsing
105
+ // ---------------------------------------------------------------------------
106
+
107
+ function parseArgs(argv) {
108
+ const args = {
109
+ repoPath: null,
110
+ outputDir: DEFAULT_INDEX_DIR,
111
+ incremental: true,
112
+ fullRebuild: false,
113
+ query: null,
114
+ verbose: false,
115
+ dryRun: false,
116
+ };
117
+
118
+ for (let i = 2; i < argv.length; i++) {
119
+ switch (argv[i]) {
120
+ case '--repo-path':
121
+ args.repoPath = argv[++i];
122
+ break;
123
+ case '--output':
124
+ args.outputDir = argv[++i];
125
+ break;
126
+ case '--incremental':
127
+ args.incremental = true;
128
+ args.fullRebuild = false;
129
+ break;
130
+ case '--full-rebuild':
131
+ args.fullRebuild = true;
132
+ args.incremental = false;
133
+ break;
134
+ case '--query':
135
+ args.query = argv[++i];
136
+ break;
137
+ case '--verbose':
138
+ args.verbose = true;
139
+ globalThis._VERBOSE = true;
140
+ break;
141
+ case '--dry-run':
142
+ args.dryRun = true;
143
+ break;
144
+ default:
145
+ // Ignore unknown flags for forward compatibility
146
+ break;
147
+ }
148
+ }
149
+
150
+ return args;
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // JSONL helpers
155
+ // ---------------------------------------------------------------------------
156
+
157
+ function readJsonl(filePath) {
158
+ if (!existsSync(filePath)) return [];
159
+ const lines = readFileSync(filePath, 'utf8').split('\n').filter(l => l.trim());
160
+ const records = [];
161
+ for (const line of lines) {
162
+ try {
163
+ records.push(JSON.parse(line));
164
+ } catch (_) {
165
+ // Skip malformed lines
166
+ }
167
+ }
168
+ return records;
169
+ }
170
+
171
+ function writeJsonl(filePath, records) {
172
+ writeFileSync(
173
+ filePath,
174
+ records.map(r => JSON.stringify(r)).join('\n') + (records.length > 0 ? '\n' : ''),
175
+ 'utf8',
176
+ );
177
+ }
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // Monthly ring-buffer helpers
181
+ // ---------------------------------------------------------------------------
182
+
183
+ /**
184
+ * Return the ISO month string (YYYY-MM) for a date offset by `delta` months
185
+ * from today. delta=0 → current month, delta=-1 → previous month, etc.
186
+ */
187
+ function isoMonth(delta = 0) {
188
+ const d = new Date();
189
+ d.setDate(1);
190
+ d.setMonth(d.getMonth() + delta);
191
+ return d.toISOString().slice(0, 7); // YYYY-MM
192
+ }
193
+
194
+ /**
195
+ * Return the three bucket labels for the 90-day window:
196
+ * [month-2, month-1, month-0]
197
+ */
198
+ function windowBuckets() {
199
+ return [isoMonth(-2), isoMonth(-1), isoMonth(0)];
200
+ }
201
+
202
+ /**
203
+ * Return the bucket index (0=oldest, 1=mid, 2=newest) for a date string,
204
+ * or -1 if the date is outside the 90-day window.
205
+ */
206
+ function bucketIndex(dateStr) {
207
+ if (!dateStr) return -1;
208
+ const month = dateStr.slice(0, 7); // YYYY-MM
209
+ const buckets = windowBuckets();
210
+ const idx = buckets.indexOf(month);
211
+ return idx; // -1 if not in window
212
+ }
213
+
214
+ /**
215
+ * Sum the values in a 3-element ring-buffer array.
216
+ */
217
+ function sumBuckets(arr) {
218
+ if (!Array.isArray(arr)) return 0;
219
+ return arr.reduce((s, v) => s + (v || 0), 0);
220
+ }
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // Forge YAML companion reader
224
+ // ---------------------------------------------------------------------------
225
+
226
+ function readCompanions(repoPath) {
227
+ const yamlPath = join(repoPath, 'forge.yaml');
228
+ if (!existsSync(yamlPath)) return [];
229
+
230
+ const text = readFileSync(yamlPath, 'utf8');
231
+ const companions = [];
232
+
233
+ // Parse companions section: list of [file1, file2] pairs
234
+ // YAML format:
235
+ // companions:
236
+ // - [package.json, package-lock.json]
237
+ // - [forge.yaml, forge.yaml.example]
238
+ const companionsMatch = text.match(/^companions:\s*\n((?:[ \t]+-[ \t]+\[.*\]\s*\n?)*)/m);
239
+ if (!companionsMatch) return [];
240
+
241
+ const pairsText = companionsMatch[1];
242
+ const pairRe = /\[\s*([^\],]+?)\s*,\s*([^\]]+?)\s*\]/g;
243
+ let m;
244
+ while ((m = pairRe.exec(pairsText)) !== null) {
245
+ const a = m[1].trim();
246
+ const b = m[2].trim();
247
+ if (a && b) {
248
+ // Store in lexicographic order
249
+ companions.push(a < b ? [a, b] : [b, a]);
250
+ }
251
+ }
252
+
253
+ return companions;
254
+ }
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // Step 1: Build per-file finding stats from knowledge.jsonl
258
+ // ---------------------------------------------------------------------------
259
+
260
+ /**
261
+ * Read knowledge.jsonl and compute per-file finding counts for the 90d window.
262
+ * Returns: Map<string, { findingCount90d, topPatterns, citedIssues, createdAt[] }>
263
+ */
264
+ function buildFileFindingStats(cards) {
265
+ const stats = new Map();
266
+ const buckets = windowBuckets();
267
+ const oldestMonth = buckets[0];
268
+
269
+ for (const card of cards) {
270
+ // Only count confirmed investigation and pattern cards
271
+ if (card.kind !== 'investigation' && card.kind !== 'pattern') continue;
272
+ if (card.kind === 'investigation' && card.verdict !== 'CONFIRMED' && card.verdict !== 'PARTIAL') continue;
273
+
274
+ // Check 90-day window
275
+ const cardMonth = (card.createdAt || '').slice(0, 7);
276
+ if (cardMonth < oldestMonth) continue; // Too old
277
+
278
+ const paths = Array.isArray(card.paths) ? card.paths : [];
279
+ for (const filePath of paths) {
280
+ if (!filePath) continue;
281
+
282
+ if (!stats.has(filePath)) {
283
+ stats.set(filePath, {
284
+ findingCount90d: 0,
285
+ topPatterns: [],
286
+ citedIssues: new Set(),
287
+ severity: null,
288
+ });
289
+ }
290
+
291
+ const entry = stats.get(filePath);
292
+ entry.findingCount90d += 1;
293
+ entry.citedIssues.add(card.issue);
294
+
295
+ // Collect pattern descriptions (top 3)
296
+ if (entry.topPatterns.length < 3 && card.pattern) {
297
+ entry.topPatterns.push(card.pattern.slice(0, 120));
298
+ } else if (entry.topPatterns.length < 3 && card.rootCause) {
299
+ entry.topPatterns.push(card.rootCause.slice(0, 120));
300
+ }
301
+
302
+ // Track highest severity seen for this file
303
+ const SEV_ORDER = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1 };
304
+ const sev = card.severity || 'LOW';
305
+ if (!entry.severity || (SEV_ORDER[sev] || 0) > (SEV_ORDER[entry.severity] || 0)) {
306
+ entry.severity = sev;
307
+ }
308
+ }
309
+ }
310
+
311
+ // Convert Sets to arrays for serialization
312
+ const result = new Map();
313
+ for (const [file, entry] of stats) {
314
+ result.set(file, {
315
+ findingCount90d: entry.findingCount90d,
316
+ topPatterns: entry.topPatterns,
317
+ citedIssues: [...entry.citedIssues].sort((a, b) => b - a).slice(0, 10),
318
+ maxSeverity: entry.severity || 'LOW',
319
+ });
320
+ }
321
+
322
+ return result;
323
+ }
324
+
325
+ // ---------------------------------------------------------------------------
326
+ // Step 2: Build co-change matrix from git history
327
+ // ---------------------------------------------------------------------------
328
+
329
+ /**
330
+ * Run git log over the 90-day window and collect per-commit file lists.
331
+ * Returns: Array<{ month: string, files: string[] }>
332
+ */
333
+ function collectCommits(repoPath, sinceDate) {
334
+ const since = sinceDate || `${WINDOW_DAYS} days ago`;
335
+
336
+ let gitOutput;
337
+ try {
338
+ // --name-only: list files changed per commit
339
+ // --pretty=format:'COMMIT %H %ai': commit hash + author date (ISO)
340
+ // No pathspec = full repo scan for matrix building (this is intentional —
341
+ // we need the global commit stream to compute n(a) and N correctly)
342
+ gitOutput = execSync(
343
+ `git -C "${repoPath}" log --name-only --since="${since}" --pretty=format:"COMMIT %H %ai"`,
344
+ { encoding: 'utf8', maxBuffer: 50 * 1024 * 1024, timeout: 60000 },
345
+ );
346
+ } catch (e) {
347
+ log(`WARNING: git log failed — ${e.message}`);
348
+ return [];
349
+ }
350
+
351
+ const commits = [];
352
+ let current = null;
353
+
354
+ for (const rawLine of gitOutput.split('\n')) {
355
+ const line = rawLine.trim();
356
+ if (!line) {
357
+ if (current && current.files.length > 0) {
358
+ commits.push(current);
359
+ }
360
+ current = null;
361
+ continue;
362
+ }
363
+
364
+ if (line.startsWith('COMMIT ')) {
365
+ if (current && current.files.length > 0) {
366
+ commits.push(current);
367
+ }
368
+ // Parse: COMMIT <hash> <ISO date>
369
+ const parts = line.split(' ');
370
+ const dateStr = parts[2] || ''; // YYYY-MM-DD...
371
+ current = { month: dateStr.slice(0, 7), files: [] };
372
+ } else if (current) {
373
+ // File path line — skip deleted files (no longer exist) or binary markers
374
+ if (!line.startsWith('Binary') && line.length > 0) {
375
+ current.files.push(line);
376
+ }
377
+ }
378
+ }
379
+
380
+ if (current && current.files.length > 0) {
381
+ commits.push(current);
382
+ }
383
+
384
+ debug(`Collected ${commits.length} commits from git log`);
385
+ return commits;
386
+ }
387
+
388
+ /**
389
+ * Build the adjacency map from commits.
390
+ *
391
+ * adjacency: Map<lexKey, { fileA, fileB, n_a: [b0,b1,b2], n_b: [b0,b1,b2], c: [b0,b1,b2] }>
392
+ * fileCounts: Map<file, [b0,b1,b2]>
393
+ * totalCommits: [b0,b1,b2] — total commits per bucket
394
+ */
395
+ function buildAdjacency(commits) {
396
+ const buckets = windowBuckets();
397
+ const fileCounts = new Map(); // file → [b0, b1, b2]
398
+ const pairCounts = new Map(); // 'fileA\0fileB' → [b0, b1, b2]
399
+ const totalCommits = [0, 0, 0];
400
+
401
+ for (const { month, files } of commits) {
402
+ const bIdx = buckets.indexOf(month);
403
+ if (bIdx < 0) continue; // Outside window — should not happen since git --since filters
404
+
405
+ totalCommits[bIdx] += 1;
406
+
407
+ // Update per-file counts
408
+ const uniqueFiles = [...new Set(files)];
409
+ for (const f of uniqueFiles) {
410
+ if (!fileCounts.has(f)) fileCounts.set(f, [0, 0, 0]);
411
+ fileCounts.get(f)[bIdx] += 1;
412
+ }
413
+
414
+ // Update pairwise co-occurrence counts (only pairs where both appear in same commit)
415
+ // For N files in a commit, there are N*(N-1)/2 pairs — cap at 50 files per commit
416
+ // to bound the quadratic cost
417
+ const cappedFiles = uniqueFiles.slice(0, 50);
418
+ for (let i = 0; i < cappedFiles.length; i++) {
419
+ for (let j = i + 1; j < cappedFiles.length; j++) {
420
+ const a = cappedFiles[i];
421
+ const b = cappedFiles[j];
422
+ // Lexicographic order — always store (smaller, larger)
423
+ const key = a < b ? `${a}\0${b}` : `${b}\0${a}`;
424
+ if (!pairCounts.has(key)) pairCounts.set(key, [0, 0, 0]);
425
+ pairCounts.get(key)[bIdx] += 1;
426
+ }
427
+ }
428
+ }
429
+
430
+ return { fileCounts, pairCounts, totalCommits };
431
+ }
432
+
433
+ /**
434
+ * Convert adjacency maps to the JSONL adjacency format.
435
+ * One record per file, partners as a map.
436
+ *
437
+ * {"file":"bin/runner.mjs","n":[3,7,6],"partners":{"bin/engine.mjs":[1,3,2]}}
438
+ */
439
+ function buildCochangeRecords(fileCounts, pairCounts) {
440
+ // Build reverse-lookup: file → { partner → [b0,b1,b2] }
441
+ const filePartners = new Map();
442
+
443
+ for (const [key, counts] of pairCounts) {
444
+ if (sumBuckets(counts) === 0) continue;
445
+ const sep = key.indexOf('\0');
446
+ const fileA = key.slice(0, sep);
447
+ const fileB = key.slice(sep + 1);
448
+
449
+ if (!filePartners.has(fileA)) filePartners.set(fileA, {});
450
+ filePartners.get(fileA)[fileB] = counts;
451
+
452
+ if (!filePartners.has(fileB)) filePartners.set(fileB, {});
453
+ filePartners.get(fileB)[fileA] = counts;
454
+ }
455
+
456
+ // Build one record per file, sorted lexicographically
457
+ const records = [];
458
+ const allFiles = new Set([...fileCounts.keys(), ...filePartners.keys()]);
459
+
460
+ for (const file of [...allFiles].sort()) {
461
+ const n = fileCounts.get(file) || [0, 0, 0];
462
+ const partners = filePartners.get(file) || {};
463
+ records.push({ file, n, partners });
464
+ }
465
+
466
+ return records;
467
+ }
468
+
469
+ // ---------------------------------------------------------------------------
470
+ // Step 3: Normalization — coupling verdict
471
+ // ---------------------------------------------------------------------------
472
+
473
+ /**
474
+ * Determine if a file pair is coupled, independent, or unknown.
475
+ *
476
+ * Returns: 'coupled' | 'independent' | 'unknown'
477
+ *
478
+ * 'unknown' means insufficient data (n < MIN_CHANGE_COUNT) — matrix must NOT
479
+ * be used to downgrade Layer 2/4 edges in this case.
480
+ */
481
+ function couplingVerdict(nA, nB, cAB, totalN) {
482
+ const sumNA = sumBuckets(nA);
483
+ const sumNB = sumBuckets(nB);
484
+ const sumC = sumBuckets(cAB);
485
+
486
+ // Cold-start: insufficient history → unknown
487
+ if (sumNA < MIN_CHANGE_COUNT || sumNB < MIN_CHANGE_COUNT) {
488
+ return 'unknown';
489
+ }
490
+
491
+ // Support threshold: must appear together at least 3 times
492
+ if (sumC < SUPPORT_THRESHOLD) {
493
+ // Zero co-occurrences = verified independent (only when n >= MIN_CHANGE_COUNT)
494
+ if (sumC === 0) return 'independent';
495
+ return 'unknown'; // 1-2 co-occurrences: insufficient support
496
+ }
497
+
498
+ // Ubiquity classification
499
+ const ubiqA = totalN > 0 && (sumNA / totalN) > UBIQUITY_THRESHOLD;
500
+ const ubiqB = totalN > 0 && (sumNB / totalN) > UBIQUITY_THRESHOLD;
501
+
502
+ if (!ubiqA && !ubiqB) {
503
+ // Standard case: c/min(n(a),n(b)) >= 0.5
504
+ const conf = sumC / Math.min(sumNA, sumNB);
505
+ return conf >= CONFIDENCE_STANDARD ? 'coupled' : 'unknown';
506
+ }
507
+
508
+ if (ubiqA && ubiqB) {
509
+ // Both ubiquitous: require mutual near-determinism
510
+ const confA = sumC / sumNA;
511
+ const confB = sumC / sumNB;
512
+ return (confA >= CONFIDENCE_MUTUAL && confB >= CONFIDENCE_MUTUAL) ? 'coupled' : 'unknown';
513
+ }
514
+
515
+ // Exactly one ubiquitous: directional confidence from the quiet side
516
+ const quietN = ubiqA ? sumNB : sumNA;
517
+ const conf = sumC / quietN;
518
+ return conf >= CONFIDENCE_DIRECTIONAL ? 'coupled' : 'unknown';
519
+ }
520
+
521
+ /**
522
+ * Returns true if a ubiquity filter applies to this pair (ineligible for
523
+ * verified-independent downgrade, per FORGE:DESIGN_DECISION addendum).
524
+ */
525
+ function isUbiquitousPair(nA, nB, totalN) {
526
+ if (totalN === 0) return false;
527
+ const ubiqA = (sumBuckets(nA) / totalN) > UBIQUITY_THRESHOLD;
528
+ const ubiqB = (sumBuckets(nB) / totalN) > UBIQUITY_THRESHOLD;
529
+ return ubiqA || ubiqB;
530
+ }
531
+
532
+ // ---------------------------------------------------------------------------
533
+ // Query mode
534
+ // ---------------------------------------------------------------------------
535
+
536
+ /**
537
+ * Print the danger-zone entry and co-change partners for a single file.
538
+ * Used by orchestrate Layer 5 and review-pr CHURN_CONTEXT.
539
+ */
540
+ function queryFile(filePath, indexDir, repoPath) {
541
+ const dzPath = join(indexDir, DANGER_ZONES_FILE);
542
+ const ccPath = join(indexDir, COCHANGE_FILE);
543
+
544
+ let dz = null;
545
+ let partners = [];
546
+
547
+ if (existsSync(dzPath)) {
548
+ try {
549
+ const dzData = JSON.parse(readFileSync(dzPath, 'utf8'));
550
+ dz = dzData.files ? dzData.files[filePath] || null : null;
551
+ } catch (_) {}
552
+ }
553
+
554
+ if (existsSync(ccPath)) {
555
+ const records = readJsonl(ccPath);
556
+ // Find record for this file (may be stored as absolute or relative path)
557
+ const rel = repoPath ? relative(repoPath, resolve(filePath)) : filePath;
558
+ const record = records.find(r => r.file === filePath || r.file === rel);
559
+ if (record && record.partners) {
560
+ // Load meta for total commits
561
+ let totalN = 0;
562
+ const metaPath = join(indexDir, COCHANGE_META_FILE);
563
+ if (existsSync(metaPath)) {
564
+ try {
565
+ totalN = JSON.parse(readFileSync(metaPath, 'utf8')).totalCommits || 0;
566
+ } catch (_) {}
567
+ }
568
+
569
+ for (const [partner, cCounts] of Object.entries(record.partners)) {
570
+ const partnerRecord = records.find(r => r.file === partner);
571
+ const nB = partnerRecord ? partnerRecord.n : [0, 0, 0];
572
+ const verdict = couplingVerdict(record.n, nB, cCounts, totalN);
573
+ if (verdict !== 'unknown') {
574
+ partners.push({ file: partner, cochange: sumBuckets(cCounts), verdict });
575
+ }
576
+ }
577
+ partners.sort((a, b) => b.cochange - a.cochange);
578
+ }
579
+ }
580
+
581
+ const result = {
582
+ file: filePath,
583
+ dangerZone: dz,
584
+ cochangePartners: partners.slice(0, 50),
585
+ };
586
+
587
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
588
+ }
589
+
590
+ // ---------------------------------------------------------------------------
591
+ // Main
592
+ // ---------------------------------------------------------------------------
593
+
594
+ async function main() {
595
+ const args = parseArgs(process.argv);
596
+
597
+ // Resolve repo path
598
+ const repoPath = args.repoPath || (() => {
599
+ try {
600
+ return execSync('git rev-parse --show-toplevel', { encoding: 'utf8', timeout: 5000 }).trim();
601
+ } catch (_) {
602
+ return process.cwd();
603
+ }
604
+ })();
605
+
606
+ const indexDir = args.outputDir;
607
+
608
+ // Query mode — print single-file result and exit
609
+ if (args.query) {
610
+ queryFile(args.query, indexDir, repoPath);
611
+ return;
612
+ }
613
+
614
+ // Ensure index directory exists
615
+ if (!args.dryRun) {
616
+ mkdirSync(indexDir, { recursive: true });
617
+ }
618
+
619
+ // ------------------------------------------------------------------
620
+ // Step 1: Load knowledge cards and build per-file finding stats
621
+ // ------------------------------------------------------------------
622
+
623
+ const cardsPath = join(indexDir, CARDS_FILE);
624
+ if (!existsSync(cardsPath)) {
625
+ log('WARNING: knowledge.jsonl not found — run build-knowledge-index.mjs first');
626
+ log(`Expected path: ${cardsPath}`);
627
+ // Don't exit — we can still build the co-change matrix without the ledger
628
+ }
629
+
630
+ const cards = readJsonl(cardsPath);
631
+ log(`Loaded ${cards.length} knowledge cards from ${cardsPath}`);
632
+
633
+ const fileFindingStats = buildFileFindingStats(cards);
634
+ log(`Computed finding stats for ${fileFindingStats.size} files`);
635
+
636
+ // ------------------------------------------------------------------
637
+ // Step 2: Build co-change matrix from git history
638
+ // ------------------------------------------------------------------
639
+
640
+ log(`Mining git history (${WINDOW_DAYS}-day window) for co-change matrix...`);
641
+
642
+ // For incremental mode, read the previous total commit count from metadata
643
+ // For full-rebuild, start fresh
644
+ let previousMeta = {};
645
+ const metaPath = join(indexDir, COCHANGE_META_FILE);
646
+ if (!args.fullRebuild && existsSync(metaPath)) {
647
+ try {
648
+ previousMeta = JSON.parse(readFileSync(metaPath, 'utf8'));
649
+ debug(`Loaded previous matrix meta: builtAt=${previousMeta.builtAt}`);
650
+ } catch (_) {}
651
+ }
652
+
653
+ // Always scan the full 90-day window to ensure the ring buffer is correct
654
+ // (incremental would need careful bucket rotation logic — simpler to rebuild
655
+ // the 90d window on each run since git log is fast for recent history)
656
+ const commits = collectCommits(repoPath);
657
+ log(`Processed ${commits.length} commits in the 90-day window`);
658
+
659
+ const { fileCounts, pairCounts, totalCommits } = buildAdjacency(commits);
660
+ const totalN = sumBuckets(totalCommits);
661
+
662
+ log(`Matrix covers ${fileCounts.size} files, ${pairCounts.size} unique pairs, ${totalN} total commits`);
663
+
664
+ // Build co-change records
665
+ const cochangeRecords = buildCochangeRecords(fileCounts, pairCounts);
666
+
667
+ // ------------------------------------------------------------------
668
+ // Step 3: Apply declared companions (axiomatic edges from forge.yaml)
669
+ // ------------------------------------------------------------------
670
+
671
+ const companions = readCompanions(repoPath);
672
+ if (companions.length > 0) {
673
+ log(`Loaded ${companions.length} declared companion pairs from forge.yaml`);
674
+ }
675
+
676
+ // We'll store companions in the danger-zones.json metadata (not in cochange.jsonl
677
+ // — companions are axiomatic and bypass the normalization formula)
678
+
679
+ // ------------------------------------------------------------------
680
+ // Step 4: Build danger-zones.json
681
+ // ------------------------------------------------------------------
682
+
683
+ // Rank files by combined risk: (findingCount90d * 2) + (partnerCount of coupled pairs)
684
+ // This gives finding density priority over co-change connectivity
685
+ const fileRiskMap = new Map();
686
+
687
+ for (const [file, stats] of fileFindingStats) {
688
+ fileRiskMap.set(file, {
689
+ findingCount90d: stats.findingCount90d,
690
+ topPatterns: stats.topPatterns,
691
+ citedIssues: stats.citedIssues,
692
+ maxSeverity: stats.maxSeverity,
693
+ hotFindings: stats.findingCount90d >= FINDING_HOT_THRESHOLD,
694
+ coupledPartnerCount: 0,
695
+ });
696
+ }
697
+
698
+ // Count coupled partners for each file (for risk scoring)
699
+ for (const record of cochangeRecords) {
700
+ let coupledCount = 0;
701
+ for (const [partner, cCounts] of Object.entries(record.partners || {})) {
702
+ const partnerRecord = cochangeRecords.find(r => r.file === partner);
703
+ const nB = partnerRecord ? partnerRecord.n : [0, 0, 0];
704
+ const verdict = couplingVerdict(record.n, nB, cCounts, totalN);
705
+ if (verdict === 'coupled') coupledCount++;
706
+ }
707
+
708
+ if (coupledCount > 0) {
709
+ if (!fileRiskMap.has(record.file)) {
710
+ fileRiskMap.set(record.file, {
711
+ findingCount90d: 0,
712
+ topPatterns: [],
713
+ citedIssues: [],
714
+ maxSeverity: 'LOW',
715
+ hotFindings: false,
716
+ coupledPartnerCount: coupledCount,
717
+ });
718
+ } else {
719
+ fileRiskMap.get(record.file).coupledPartnerCount = coupledCount;
720
+ }
721
+ }
722
+ }
723
+
724
+ // Sort files by risk score (descending)
725
+ const rankedFiles = [...fileRiskMap.entries()]
726
+ .map(([file, stats]) => ({
727
+ file,
728
+ riskScore: (stats.findingCount90d * 2) + stats.coupledPartnerCount,
729
+ ...stats,
730
+ }))
731
+ .sort((a, b) => b.riskScore - a.riskScore);
732
+
733
+ const dangerZonesData = {
734
+ schemaVersion: 1,
735
+ builtAt: new Date().toISOString(),
736
+ windowDays: WINDOW_DAYS,
737
+ totalFiles: rankedFiles.length,
738
+ findingHotThreshold: FINDING_HOT_THRESHOLD,
739
+ supportThreshold: SUPPORT_THRESHOLD,
740
+ companions: companions.map(([a, b]) => ({ fileA: a, fileB: b })),
741
+ files: Object.fromEntries(rankedFiles.map(({ file, ...rest }) => [file, rest])),
742
+ ranked: rankedFiles.map(r => r.file).slice(0, 50), // Top 50 for quick access
743
+ };
744
+
745
+ // ------------------------------------------------------------------
746
+ // Step 5: Write outputs
747
+ // ------------------------------------------------------------------
748
+
749
+ if (!args.dryRun) {
750
+ writeFileSync(join(indexDir, DANGER_ZONES_FILE), JSON.stringify(dangerZonesData, null, 2), 'utf8');
751
+ log(`Wrote danger-zones.json (${rankedFiles.length} files)`);
752
+
753
+ writeJsonl(join(indexDir, COCHANGE_FILE), cochangeRecords);
754
+ log(`Wrote cochange.jsonl (${cochangeRecords.length} file records)`);
755
+
756
+ // Write metadata for incremental runs
757
+ writeFileSync(metaPath, JSON.stringify({
758
+ schemaVersion: 1,
759
+ builtAt: new Date().toISOString(),
760
+ windowDays: WINDOW_DAYS,
761
+ totalCommits: totalN,
762
+ totalFiles: fileCounts.size,
763
+ totalPairs: pairCounts.size,
764
+ buckets: windowBuckets(),
765
+ }, null, 2), 'utf8');
766
+ log(`Wrote cochange-meta.json`);
767
+ } else {
768
+ // Dry run: print top 10 danger-zone files
769
+ process.stdout.write('[danger-zones] DRY RUN — top 10 risk files:\n');
770
+ for (const entry of rankedFiles.slice(0, 10)) {
771
+ process.stdout.write(` ${entry.file}: riskScore=${entry.riskScore} findings=${entry.findingCount90d} coupledPartners=${entry.coupledPartnerCount}\n`);
772
+ }
773
+ }
774
+
775
+ const topFiles = rankedFiles.slice(0, 5).map(r => `${r.file} (score=${r.riskScore})`).join(', ');
776
+ log(`Top risk files: ${topFiles || '(none)'}`);
777
+ }
778
+
779
+ main().catch(e => {
780
+ process.stderr.write(`[danger-zones] FATAL: ${e.message}\n${e.stack}\n`);
781
+ process.exit(1);
782
+ });