forgedock 1.1.6 → 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.
- package/README.md +3 -1
- package/bin/engine/invariants.mjs +361 -0
- package/bin/hooks/pre-tool-use.mjs +182 -0
- package/bin/journey.mjs +77 -7
- package/bin/labels.json +10 -0
- package/bin/recall.mjs +540 -0
- package/commands/autopilot.md +959 -78
- package/commands/cleanup.md +52 -3
- package/commands/optimize.md +585 -0
- package/commands/orchestrate/phase-1-resolve.md +35 -25
- package/commands/orchestrate/phase-3-dependency.md +465 -34
- package/commands/orchestrate/phase-4-execution.md +448 -29
- package/commands/orchestrate/phase-6-report.md +198 -9
- package/commands/pipeline-health/phase-2-metrics.md +305 -1
- package/commands/pipeline-health/phase-4-proposals.md +127 -0
- package/commands/quality-gate.md +417 -0
- package/commands/review-pr.md +391 -13
- package/commands/spec-doctor.md +429 -0
- package/commands/validate.md +52 -0
- package/commands/work-on/build/architect.md +77 -0
- package/commands/work-on/build/context.md +357 -22
- package/commands/work-on/build/implement.md +2 -0
- package/commands/work-on/build/validate.md +28 -0
- package/commands/work-on/build.md +44 -0
- package/commands/work-on/close.md +621 -8
- package/commands/work-on/investigate.md +221 -0
- package/commands/work-on.md +15 -14
- package/docs/FORGE-PROTOCOL.md +101 -0
- package/docs/design/awesome-list-submissions.md +105 -0
- package/docs/design/f5bot-monitoring-setup.md +43 -0
- package/docs/design/growth-strategy-2026.md +579 -0
- package/docs/spec/forge-protocol-v1.md +97 -5
- package/docs/spec/label-state-machine.md +6 -1
- package/package.json +2 -2
- package/packages/protocol/src/cli.js +374 -84
- package/packages/protocol/src/types.js +75 -1
- package/scripts/build-knowledge-index.mjs +1761 -0
- package/scripts/calibration.mjs +996 -0
- package/scripts/danger-zones.mjs +782 -0
- package/scripts/doctor-pipeline-state.sh +88 -2
- package/scripts/refresh-bot-token.sh +97 -0
- package/scripts/select-fix-targets.sh +234 -0
- 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
|
+
});
|