@dogfood-lab/findings 1.2.1

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.
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Append-only review event log.
3
+ *
4
+ * Events are stored as YAML arrays in reviews/<YYYY>/<date>-finding-review-log.yaml
5
+ */
6
+
7
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync } from 'node:fs';
8
+ import { resolve, dirname, join } from 'node:path';
9
+ import { randomBytes } from 'node:crypto';
10
+ import yaml from 'js-yaml';
11
+
12
+ import { withFileLock } from '../lib/file-lock.js';
13
+ import { renameWithRetry } from '../lib/rename-with-retry.js';
14
+
15
+ let _eventCounter = 0;
16
+
17
+ /**
18
+ * Generate a unique event ID.
19
+ */
20
+ export function generateEventId() {
21
+ const ts = Date.now().toString(36);
22
+ const seq = (++_eventCounter).toString(36).padStart(4, '0');
23
+ return `rev-${ts}-${seq}`;
24
+ }
25
+
26
+ /**
27
+ * Create a review event object.
28
+ */
29
+ export function createEvent(params) {
30
+ const event = {
31
+ review_event_id: generateEventId(),
32
+ finding_id: params.findingId,
33
+ timestamp: new Date().toISOString(),
34
+ actor: params.actor,
35
+ action: params.action,
36
+ from_status: params.fromStatus,
37
+ to_status: params.toStatus
38
+ };
39
+
40
+ if (params.reason) event.reason = params.reason;
41
+ if (params.fieldChanges && Object.keys(params.fieldChanges).length > 0) {
42
+ event.field_changes = params.fieldChanges;
43
+ }
44
+ if (params.mergedFromIds?.length) event.merged_from_ids = params.mergedFromIds;
45
+ if (params.invalidatedBy) event.invalidated_by = params.invalidatedBy;
46
+ if (params.notes) event.notes = params.notes;
47
+
48
+ return event;
49
+ }
50
+
51
+ /**
52
+ * Get the log file path for a given date.
53
+ */
54
+ export function getLogPath(rootDir, date = new Date()) {
55
+ const year = date.getFullYear();
56
+ const month = String(date.getMonth() + 1).padStart(2, '0');
57
+ const day = String(date.getDate()).padStart(2, '0');
58
+ return resolve(rootDir, 'reviews', String(year), `${year}-${month}-${day}-finding-review-log.yaml`);
59
+ }
60
+
61
+ /**
62
+ * Append an event to the review log.
63
+ *
64
+ * Atomicity: writes the new event list to a unique temp file then renames it
65
+ * over the canonical log file. `rename` is atomic on POSIX and Windows, so a
66
+ * concurrent reader sees either the old contents or the new contents — never
67
+ * a half-written file. This also makes the operation crash-safe: a Ctrl+C
68
+ * between read and write leaves the original log intact.
69
+ *
70
+ * Concurrency: serialized at the choke point via `withFileLock` on the daily
71
+ * log file (F-PIPELINE-011 / W3-PIPE-001 — Pattern #4 choke-point fix). The
72
+ * read-then-write window is closed by holding a directory-mutex (`<logPath>.lock`)
73
+ * across the read → push → rename sequence. Two concurrent `appendEvent` calls
74
+ * to the SAME daily log serialize against each other; calls to DIFFERENT daily
75
+ * logs (e.g. across a midnight boundary) do not contend. The lock is reclaimed
76
+ * if the holder process dies — see `lib/file-lock.js` for the full design
77
+ * rationale (why a lock dir, why not `O_APPEND`, stale recovery semantics,
78
+ * single-machine scope).
79
+ */
80
+ export function appendEvent(rootDir, event) {
81
+ const logPath = getLogPath(rootDir);
82
+ const dir = dirname(logPath);
83
+ mkdirSync(dir, { recursive: true });
84
+
85
+ // FAILS-then-PASSES proof gate (W3-PIPE-001):
86
+ // Set DISABLE_APPEND_LOCK=1 in the env to bypass the lock for the explicit
87
+ // purpose of demonstrating the race-detection test fails without the fix.
88
+ // Wave-30 receipt documents the proof: with the lock, the multi-process
89
+ // test passes 50/50 forks across 3 iterations, 20 consecutive test runs.
90
+ // With the lock disabled, the test reliably fails (rename collisions on
91
+ // unprotected concurrent rebuilds, dropped events).
92
+ if (process.env.DISABLE_APPEND_LOCK) {
93
+ let events = [];
94
+ if (existsSync(logPath)) {
95
+ const raw = readFileSync(logPath, 'utf-8');
96
+ events = yaml.load(raw) || [];
97
+ if (!Array.isArray(events)) events = [events];
98
+ }
99
+ events.push(event);
100
+ const tmpSuffix = randomBytes(4).toString('hex');
101
+ const tmpPath = `${logPath}.${tmpSuffix}.tmp`;
102
+ writeFileSync(tmpPath, yaml.dump(events, { lineWidth: 120, noRefs: true }), 'utf-8');
103
+ // Windows EPERM/EBUSY on rename can fire transiently when AV or
104
+ // Search Indexer holds a handle to the freshly written temp. Retry.
105
+ renameWithRetry(tmpPath, logPath);
106
+ return logPath;
107
+ }
108
+
109
+ return withFileLock(logPath, () => {
110
+ let events = [];
111
+ // Read-or-empty without an `existsSync` precheck: the readFileSync call
112
+ // either returns the bytes or throws ENOENT. Avoiding `existsSync` here
113
+ // closes a Windows-specific TOCTOU window where the dirent cache could
114
+ // report `existsSync(logPath) === false` immediately after a sibling
115
+ // process renamed a fresh file into place — which would cause us to
116
+ // start with `events = []` and silently OVERWRITE the sibling's events.
117
+ // The lock alone wasn't enough; the existsSync gate was the bug.
118
+ try {
119
+ const raw = readFileSync(logPath, 'utf-8');
120
+ const parsed = yaml.load(raw);
121
+ if (parsed) events = Array.isArray(parsed) ? parsed : [parsed];
122
+ } catch (err) {
123
+ if (!err || err.code !== 'ENOENT') throw err;
124
+ }
125
+
126
+ events.push(event);
127
+
128
+ // Atomic write: temp file → rename. Same pattern persist.js + rebuild-indexes.js use.
129
+ const tmpSuffix = randomBytes(4).toString('hex');
130
+ const tmpPath = `${logPath}.${tmpSuffix}.tmp`;
131
+ writeFileSync(tmpPath, yaml.dump(events, { lineWidth: 120, noRefs: true }), 'utf-8');
132
+ // renameWithRetry: tolerate the Windows EPERM/EBUSY transient handle race
133
+ // even though we hold the per-file lock — antivirus/Search Indexer can
134
+ // still grab a handle on the temp during the rename window.
135
+ renameWithRetry(tmpPath, logPath);
136
+ return logPath;
137
+ });
138
+ }
139
+
140
+ /**
141
+ * Read all events for a specific finding.
142
+ */
143
+ export function getEventsForFinding(rootDir, findingId) {
144
+ const all = getAllEvents(rootDir);
145
+ return all.filter(e => e.finding_id === findingId);
146
+ }
147
+
148
+ /**
149
+ * Read all events across all findings.
150
+ */
151
+ export function getAllEvents(rootDir) {
152
+ const reviewsDir = resolve(rootDir, 'reviews');
153
+ if (!existsSync(reviewsDir)) return [];
154
+
155
+ const events = [];
156
+ walkYaml(reviewsDir, data => {
157
+ if (Array.isArray(data)) events.push(...data);
158
+ });
159
+
160
+ return events.sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
161
+ }
162
+
163
+ /** Walk directory tree for .yaml files, parse and call cb with data. */
164
+ function walkYaml(dir, cb) {
165
+ for (const entry of readdirSync(dir)) {
166
+ const full = join(dir, entry);
167
+ try {
168
+ if (statSync(full).isDirectory()) {
169
+ walkYaml(full, cb);
170
+ } else if (entry.endsWith('.yaml')) {
171
+ const raw = readFileSync(full, 'utf-8');
172
+ const data = yaml.load(raw);
173
+ if (data) cb(data);
174
+ }
175
+ } catch { /* skip bad files */ }
176
+ }
177
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Review system exports.
3
+ */
4
+ export { isLawfulTransition, validateTransition, ACTION_TARGET_STATUS, REASON_REQUIRED } from './transitions.js';
5
+ export { createEvent, appendEvent, getEventsForFinding, getAllEvents, getLogPath, generateEventId } from './event-log.js';
6
+ export { performAction, performMerge, getReviewQueue } from './review-engine.js';
@@ -0,0 +1,288 @@
1
+ /**
2
+ * Review engine for dogfood findings.
3
+ *
4
+ * Performs operator actions (accept, reject, edit, merge, reopen, invalidate, supersede)
5
+ * with state-machine enforcement, event logging, and artifact mutation.
6
+ */
7
+
8
+ import { readFileSync } from 'node:fs';
9
+ import { resolve } from 'node:path';
10
+ import yaml from 'js-yaml';
11
+
12
+ import { validateTransition, ACTION_TARGET_STATUS, REASON_REQUIRED, REQUIRES_ACCEPTED, REQUIRES_CLOSED } from './transitions.js';
13
+ import { createEvent, appendEvent } from './event-log.js';
14
+ import { parseFinding } from '../validate.js';
15
+ import { findById, loadFindings } from '../reader.js';
16
+ import { atomicWriteFileSync } from '../lib/atomic-write.js';
17
+
18
+ /**
19
+ * Perform a review action on a finding.
20
+ *
21
+ * @param {string} rootDir - dogfood-labs repo root
22
+ * @param {object} params
23
+ * @param {string} params.findingId
24
+ * @param {string} params.action - review|accept|reject|edit|merge|reopen|invalidate|supersede
25
+ * @param {string} params.actor
26
+ * @param {string} [params.reason]
27
+ * @param {string} [params.rejectReason] - structured reject reason enum
28
+ * @param {object} [params.fieldChanges] - { fieldName: newValue }
29
+ * @param {string[]} [params.mergeSourceIds] - for merge action
30
+ * @param {string} [params.supersededBy] - for supersede action
31
+ * @param {string} [params.notes]
32
+ * @returns {{ success: boolean, error?: string, finding?: object, event?: object }}
33
+ */
34
+ export function performAction(rootDir, params) {
35
+ const { findingId, action, actor } = params;
36
+
37
+ if (!findingId) return { success: false, error: 'findingId is required' };
38
+ if (!action) return { success: false, error: 'action is required' };
39
+ if (!actor) return { success: false, error: 'actor is required' };
40
+
41
+ // Load the finding
42
+ const result = findById(rootDir, findingId);
43
+ if (!result) return { success: false, error: `Finding not found: ${findingId}` };
44
+
45
+ const finding = result.data;
46
+ const filePath = result.path;
47
+ const fromStatus = finding.status;
48
+
49
+ // Enforce reason requirement
50
+ if (REASON_REQUIRED.has(action) && !params.reason) {
51
+ return { success: false, error: `Action "${action}" requires a reason` };
52
+ }
53
+
54
+ // Enforce accepted-only actions
55
+ if (REQUIRES_ACCEPTED.has(action) && fromStatus !== 'accepted') {
56
+ return { success: false, error: `Action "${action}" requires status "accepted", got "${fromStatus}"` };
57
+ }
58
+
59
+ // Enforce closed-only actions (reopen)
60
+ if (REQUIRES_CLOSED.has(action) && fromStatus !== 'accepted' && fromStatus !== 'rejected') {
61
+ return { success: false, error: `Action "${action}" requires status "accepted" or "rejected", got "${fromStatus}"` };
62
+ }
63
+
64
+ // Determine target status
65
+ let toStatus = ACTION_TARGET_STATUS[action];
66
+ if (toStatus === null) {
67
+ // Edit preserves current status
68
+ toStatus = fromStatus;
69
+ }
70
+
71
+ // Validate transition (except edit which doesn't change status)
72
+ if (action !== 'edit' && toStatus !== fromStatus) {
73
+ const transResult = validateTransition(fromStatus, toStatus);
74
+ if (!transResult.valid) {
75
+ return { success: false, error: transResult.error };
76
+ }
77
+ }
78
+
79
+ // Build field changes for edit action
80
+ const fieldChanges = {};
81
+ if (action === 'edit' && params.fieldChanges) {
82
+ for (const [field, newValue] of Object.entries(params.fieldChanges)) {
83
+ // Prototype-pollution guard: `field` is operator-supplied via CLI/API.
84
+ // Writing to `__proto__` / `constructor` / `prototype` would mutate
85
+ // Object.prototype for the review-engine process and silently corrupt
86
+ // every downstream YAML dump + reload. Reject these keys outright so
87
+ // the operator sees a structured error instead of a poisoned process.
88
+ if (field === '__proto__' || field === 'constructor' || field === 'prototype') {
89
+ return { success: false, error: `Field "${field}" is not editable (reserved/unsafe key)` };
90
+ }
91
+ const oldValue = finding[field];
92
+ if (oldValue !== newValue) {
93
+ fieldChanges[field] = { from: oldValue, to: newValue };
94
+ finding[field] = newValue;
95
+ }
96
+ }
97
+ }
98
+
99
+ // Apply status change
100
+ finding.status = toStatus;
101
+
102
+ // Apply review metadata
103
+ const now = new Date().toISOString();
104
+ finding.review = {
105
+ reviewed_by: actor,
106
+ reviewed_at: now,
107
+ last_action: action,
108
+ ...(params.reason ? { decision_reason: params.reason } : {}),
109
+ ...(params.notes ? { review_notes: params.notes } : {}),
110
+ ...(params.rejectReason && action === 'reject' ? { reject_reason: params.rejectReason } : {})
111
+ };
112
+
113
+ // Update timestamps
114
+ finding.updated_at = now;
115
+
116
+ // Handle invalidation
117
+ if (action === 'invalidate') {
118
+ finding.invalidation = {
119
+ is_invalidated: true,
120
+ invalidated_at: now,
121
+ reason: params.reason
122
+ };
123
+ }
124
+
125
+ // Handle supersede
126
+ if (action === 'supersede') {
127
+ if (!params.supersededBy) {
128
+ return { success: false, error: 'supersede action requires supersededBy' };
129
+ }
130
+ if (!finding.lineage) finding.lineage = {};
131
+ finding.lineage.superseded_by = params.supersededBy;
132
+ }
133
+
134
+ // Create review event
135
+ const event = createEvent({
136
+ findingId,
137
+ actor,
138
+ action,
139
+ fromStatus,
140
+ toStatus,
141
+ reason: params.reason,
142
+ fieldChanges: Object.keys(fieldChanges).length > 0 ? fieldChanges : undefined,
143
+ mergedFromIds: params.mergeSourceIds,
144
+ notes: params.notes
145
+ });
146
+
147
+ // Persist: update finding artifact
148
+ const clean = JSON.parse(JSON.stringify(finding));
149
+ atomicWriteFileSync(filePath, yaml.dump(clean, { lineWidth: 120, noRefs: true }));
150
+
151
+ // Persist: append event to log
152
+ appendEvent(rootDir, event);
153
+
154
+ return { success: true, finding, event };
155
+ }
156
+
157
+ /**
158
+ * Merge multiple findings into one canonical finding.
159
+ *
160
+ * @param {string} rootDir
161
+ * @param {object} params
162
+ * @param {string[]} params.sourceIds - Finding IDs to merge
163
+ * @param {string} params.canonicalId - Target finding ID (must exist or be one of sourceIds)
164
+ * @param {string} params.actor
165
+ * @param {string} params.reason
166
+ * @returns {{ success: boolean, error?: string, canonical?: object, events?: object[] }}
167
+ */
168
+ export function performMerge(rootDir, params) {
169
+ const { sourceIds, canonicalId, actor, reason } = params;
170
+
171
+ if (!sourceIds?.length || sourceIds.length < 2) {
172
+ return { success: false, error: 'Merge requires at least 2 source finding IDs' };
173
+ }
174
+ if (!canonicalId) return { success: false, error: 'canonicalId is required' };
175
+ if (!actor) return { success: false, error: 'actor is required' };
176
+ if (!reason) return { success: false, error: 'Merge requires a reason' };
177
+
178
+ // Load all source findings
179
+ const sources = [];
180
+ for (const id of sourceIds) {
181
+ const result = findById(rootDir, id);
182
+ if (!result) return { success: false, error: `Source finding not found: ${id}` };
183
+ sources.push(result);
184
+ }
185
+
186
+ // Find canonical (must be one of the sources)
187
+ const canonicalResult = sources.find(s => s.data.finding_id === canonicalId);
188
+ if (!canonicalResult) {
189
+ return { success: false, error: `Canonical ID "${canonicalId}" must be one of the source IDs` };
190
+ }
191
+
192
+ const canonical = canonicalResult.data;
193
+ const nonCanonical = sources.filter(s => s.data.finding_id !== canonicalId);
194
+
195
+ // Merge evidence and source_record_ids into canonical
196
+ const mergedRecordIds = new Set(canonical.source_record_ids || []);
197
+ const mergedEvidence = [...(canonical.evidence || [])];
198
+ const mergedScenarioIds = new Set(canonical.scenario_ids || []);
199
+
200
+ for (const s of nonCanonical) {
201
+ for (const rid of (s.data.source_record_ids || [])) mergedRecordIds.add(rid);
202
+ for (const sid of (s.data.scenario_ids || [])) mergedScenarioIds.add(sid);
203
+ for (const ev of (s.data.evidence || [])) {
204
+ // Dedupe evidence by kind+record_id+scenario_id
205
+ const key = `${ev.evidence_kind}:${ev.record_id || ''}:${ev.scenario_id || ''}`;
206
+ const exists = mergedEvidence.some(e =>
207
+ `${e.evidence_kind}:${e.record_id || ''}:${e.scenario_id || ''}` === key
208
+ );
209
+ if (!exists) mergedEvidence.push(ev);
210
+ }
211
+ }
212
+
213
+ // Update canonical
214
+ canonical.source_record_ids = [...mergedRecordIds];
215
+ canonical.scenario_ids = [...mergedScenarioIds];
216
+ canonical.evidence = mergedEvidence;
217
+ canonical.lineage = {
218
+ ...(canonical.lineage || {}),
219
+ merged_from: nonCanonical.map(s => s.data.finding_id)
220
+ };
221
+
222
+ const now = new Date().toISOString();
223
+ canonical.updated_at = now;
224
+ canonical.review = {
225
+ reviewed_by: actor,
226
+ reviewed_at: now,
227
+ last_action: 'merge',
228
+ decision_reason: reason
229
+ };
230
+
231
+ // Write canonical
232
+ const cleanCanonical = JSON.parse(JSON.stringify(canonical));
233
+ atomicWriteFileSync(canonicalResult.path, yaml.dump(cleanCanonical, { lineWidth: 120, noRefs: true }));
234
+
235
+ // Mark source findings as rejected/superseded
236
+ const events = [];
237
+ for (const s of nonCanonical) {
238
+ const sourceResult = performAction(rootDir, {
239
+ findingId: s.data.finding_id,
240
+ action: 'supersede',
241
+ actor,
242
+ reason: `Merged into ${canonicalId}`,
243
+ supersededBy: canonicalId
244
+ });
245
+ if (sourceResult.event) events.push(sourceResult.event);
246
+ }
247
+
248
+ // Log merge event for canonical
249
+ const mergeEvent = createEvent({
250
+ findingId: canonicalId,
251
+ actor,
252
+ action: 'merge',
253
+ fromStatus: canonical.status,
254
+ toStatus: canonical.status,
255
+ reason,
256
+ mergedFromIds: nonCanonical.map(s => s.data.finding_id)
257
+ });
258
+ appendEvent(rootDir, mergeEvent);
259
+ events.push(mergeEvent);
260
+
261
+ return { success: true, canonical, events };
262
+ }
263
+
264
+ /**
265
+ * Get the review queue: findings needing operator attention.
266
+ *
267
+ * @param {string} rootDir
268
+ * @returns {Array<{ data: object, reason: string }>}
269
+ */
270
+ export function getReviewQueue(rootDir) {
271
+ const allFindings = loadFindings(rootDir);
272
+ const queue = [];
273
+
274
+ for (const f of allFindings) {
275
+ if (!f.data) continue;
276
+
277
+ // Invalidation check first — takes priority over status-based matching
278
+ if (f.data.invalidation?.is_invalidated) {
279
+ queue.push({ data: f.data, path: f.path, queueReason: 'Invalidated — needs resolution' });
280
+ } else if (f.data.status === 'candidate') {
281
+ queue.push({ data: f.data, path: f.path, queueReason: 'Unreviewed candidate' });
282
+ } else if (f.data.status === 'reviewed') {
283
+ queue.push({ data: f.data, path: f.path, queueReason: 'Reviewed but unresolved' });
284
+ }
285
+ }
286
+
287
+ return queue;
288
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Status transition law for findings.
3
+ *
4
+ * Lawful transitions:
5
+ * candidate -> reviewed, accepted, rejected
6
+ * reviewed -> accepted, rejected
7
+ * accepted -> reviewed (via reopen/invalidate only)
8
+ * accepted -> rejected (via invalidation/reversal only)
9
+ * rejected -> reviewed (via reopen only)
10
+ *
11
+ * Forbidden:
12
+ * rejected -> candidate (no rewinding to machine output)
13
+ * any status -> candidate (except initial creation)
14
+ */
15
+
16
+ const TRANSITIONS = {
17
+ candidate: new Set(['reviewed', 'accepted', 'rejected']),
18
+ reviewed: new Set(['accepted', 'rejected']),
19
+ accepted: new Set(['reviewed', 'rejected']),
20
+ rejected: new Set(['reviewed'])
21
+ };
22
+
23
+ /**
24
+ * Check if a status transition is lawful.
25
+ * @param {string} from - Current status.
26
+ * @param {string} to - Desired status.
27
+ * @returns {boolean}
28
+ */
29
+ export function isLawfulTransition(from, to) {
30
+ const allowed = TRANSITIONS[from];
31
+ if (!allowed) return false;
32
+ return allowed.has(to);
33
+ }
34
+
35
+ /**
36
+ * Validate a transition and return error if invalid.
37
+ * @param {string} from
38
+ * @param {string} to
39
+ * @returns {{ valid: boolean, error?: string }}
40
+ */
41
+ export function validateTransition(from, to) {
42
+ if (!TRANSITIONS[from]) {
43
+ return { valid: false, error: `Unknown status: "${from}"` };
44
+ }
45
+ if (!isLawfulTransition(from, to)) {
46
+ const allowed = [...TRANSITIONS[from]].join(', ');
47
+ return { valid: false, error: `Cannot transition from "${from}" to "${to}". Allowed: ${allowed}` };
48
+ }
49
+ return { valid: true };
50
+ }
51
+
52
+ /**
53
+ * Map review actions to their target statuses.
54
+ */
55
+ export const ACTION_TARGET_STATUS = {
56
+ review: 'reviewed',
57
+ accept: 'accepted',
58
+ reject: 'rejected',
59
+ edit: null, // edit preserves current status
60
+ merge: 'rejected', // merged sources become rejected (merged_into_canonical)
61
+ reopen: 'reviewed',
62
+ invalidate: 'reviewed', // invalidated accepted → back to reviewed with invalidation metadata
63
+ supersede: 'rejected' // superseded finding becomes rejected
64
+ };
65
+
66
+ /**
67
+ * Actions that require a reason.
68
+ */
69
+ export const REASON_REQUIRED = new Set(['reject', 'invalidate', 'merge', 'supersede']);
70
+
71
+ /**
72
+ * Actions that require from_status to be accepted.
73
+ */
74
+ export const REQUIRES_ACCEPTED = new Set(['invalidate']);
75
+
76
+ /**
77
+ * Actions that require from_status to be accepted or rejected.
78
+ */
79
+ export const REQUIRES_CLOSED = new Set(['reopen']);
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Doctrine derivation from strong accepted patterns.
3
+ *
4
+ * Doctrine is the most conservative artifact in the system.
5
+ * Requirements:
6
+ * - At least 1 accepted pattern (2+ for org_wide scope)
7
+ * - Pattern strength must be 'strong' or 'portfolio_stable'
8
+ * - Statement must be rule-like, not advisory
9
+ */
10
+
11
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
12
+ import { resolve, join } from 'node:path';
13
+ import yaml from 'js-yaml';
14
+ import { loadAcceptedPatterns } from './recommendation-derivation.js';
15
+
16
+ /**
17
+ * Derive doctrine from strong accepted patterns.
18
+ *
19
+ * @param {string} rootDir - dogfood-labs repo root
20
+ * @returns {{ doctrines: Array, stats: { patternsConsidered: number, doctrinesEmitted: number, belowThreshold: number } }}
21
+ */
22
+ export function deriveDoctrine(rootDir) {
23
+ const patterns = loadAcceptedPatterns(rootDir);
24
+ const strong = patterns.filter(p =>
25
+ p.pattern_strength === 'strong' || p.pattern_strength === 'portfolio_stable'
26
+ );
27
+
28
+ const doctrines = [];
29
+ let belowThreshold = 0;
30
+
31
+ // Group strong patterns by shared doctrine theme
32
+ const themes = groupByDoctrineTheme(strong);
33
+
34
+ for (const [theme, themePatterns] of themes) {
35
+ // org_wide doctrine requires 2+ patterns
36
+ const maxScope = widestScope(themePatterns);
37
+ if (maxScope === 'org_wide' && themePatterns.length < 2) {
38
+ belowThreshold++;
39
+ continue;
40
+ }
41
+
42
+ doctrines.push(buildDoctrineCandidate(theme, themePatterns));
43
+ }
44
+
45
+ return {
46
+ doctrines,
47
+ stats: {
48
+ patternsConsidered: patterns.length,
49
+ doctrinesEmitted: doctrines.length,
50
+ belowThreshold
51
+ }
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Group patterns by doctrine theme (shared root cause family).
57
+ */
58
+ function groupByDoctrineTheme(patterns) {
59
+ const themes = new Map();
60
+ for (const p of patterns) {
61
+ const rootCauses = p.dimensions?.root_cause_kinds || [];
62
+ const theme = rootCauses[0] || 'general';
63
+ if (!themes.has(theme)) themes.set(theme, []);
64
+ themes.get(theme).push(p);
65
+ }
66
+ return themes;
67
+ }
68
+
69
+ function widestScope(patterns) {
70
+ const order = ['repo_local', 'surface_local', 'surface_archetype', 'execution_mode', 'org_wide'];
71
+ let widest = 0;
72
+ for (const p of patterns) {
73
+ const idx = order.indexOf(p.transfer_scope);
74
+ if (idx > widest) widest = idx;
75
+ }
76
+ return order[widest];
77
+ }
78
+
79
+ function buildDoctrineCandidate(theme, patterns) {
80
+ const now = new Date().toISOString();
81
+ const issueKinds = [...new Set(patterns.flatMap(p => p.dimensions?.issue_kinds || []))];
82
+ const surfaces = [...new Set(patterns.flatMap(p => p.dimensions?.product_surfaces || []))];
83
+ const scope = widestScope(patterns);
84
+
85
+ const kind = classifyDoctrineKind(issueKinds, theme);
86
+ const slug = `${theme}-${kind}`.replace(/_/g, '-');
87
+
88
+ return {
89
+ schema_version: '1.0.0',
90
+ doctrine_id: `ddoc-${slug}`,
91
+ title: buildDoctrineTitle(theme, issueKinds, surfaces),
92
+ status: 'candidate',
93
+ doctrine_kind: kind,
94
+ statement: buildDoctrineStatement(theme, issueKinds, surfaces),
95
+ rationale: buildDoctrineRationale(patterns, theme),
96
+ based_on_pattern_ids: patterns.map(p => p.pattern_id),
97
+ transfer_scope: scope === 'repo_local' || scope === 'surface_local' ? 'surface_archetype' : scope,
98
+ strength: patterns.length >= 3 ? 'foundational' : 'proven',
99
+ created_at: now,
100
+ updated_at: now
101
+ };
102
+ }
103
+
104
+ function classifyDoctrineKind(issueKinds, theme) {
105
+ if (/evidence/.test(theme) || issueKinds.some(k => /evidence/.test(k))) return 'evidence_law';
106
+ if (/surface|interface/.test(theme)) return 'surface_law';
107
+ if (/policy|calibration/.test(theme)) return 'calibration_law';
108
+ if (/verification|provenance/.test(theme)) return 'verification_law';
109
+ return 'rollout_law';
110
+ }
111
+
112
+ function buildDoctrineTitle(theme, issueKinds, surfaces) {
113
+ const label = theme.replace(/_/g, ' ');
114
+ const surfaceStr = surfaces.length ? surfaces.join(', ') : 'all surfaces';
115
+ return `${label}: verified rule for ${surfaceStr}`;
116
+ }
117
+
118
+ function buildDoctrineStatement(theme, issueKinds, surfaces) {
119
+ const issueLabel = issueKinds.map(k => k.replace(/_/g, ' ')).join(' and ');
120
+ const surfaceStr = surfaces.length ? surfaces.join(', ') : 'all product surfaces';
121
+ return `Verify ${issueLabel} truth before authoring rollout assumptions for ${surfaceStr}. This is a proven recurring failure class — do not skip this step.`;
122
+ }
123
+
124
+ function buildDoctrineRationale(patterns, theme) {
125
+ const findingCount = patterns.reduce((sum, p) => sum + (p.support?.finding_count || 0), 0);
126
+ const repoCount = patterns.reduce((sum, p) => sum + (p.support?.repo_count || 0), 0);
127
+ return `Backed by ${patterns.length} accepted pattern(s) covering ${findingCount} findings across ${repoCount} repo(s). The ${theme.replace(/_/g, ' ')} root cause recurs independently across multiple contexts, confirming this is structural, not incidental.`;
128
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Synthesis layer exports.
3
+ */
4
+ export { derivePatterns } from './pattern-derivation.js';
5
+ export { deriveRecommendations, loadAcceptedPatterns } from './recommendation-derivation.js';
6
+ export { deriveDoctrine } from './doctrine-derivation.js';
7
+ export { validatePattern, validateRecommendation, validateDoctrine } from './validate-artifacts.js';
8
+ export { writePattern, writeRecommendation, writeDoctrine, loadPatterns, loadRecommendations, loadDoctrines } from './write-artifacts.js';