@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.
- package/LICENSE +21 -0
- package/README.md +96 -0
- package/advise/advice-bundle.js +155 -0
- package/advise/index.js +5 -0
- package/advise/query.js +182 -0
- package/cli.js +936 -0
- package/derive/dedupe.js +107 -0
- package/derive/derive-findings.js +187 -0
- package/derive/ids.js +48 -0
- package/derive/index.js +9 -0
- package/derive/load-records.js +153 -0
- package/derive/rules.js +415 -0
- package/derive/write-findings.js +63 -0
- package/index.js +11 -0
- package/lib/atomic-write.js +47 -0
- package/lib/file-lock.js +359 -0
- package/lib/rename-with-retry.js +43 -0
- package/package.json +70 -0
- package/reader.js +156 -0
- package/review/event-log.js +177 -0
- package/review/index.js +6 -0
- package/review/review-engine.js +288 -0
- package/review/transitions.js +79 -0
- package/synthesis/doctrine-derivation.js +128 -0
- package/synthesis/index.js +8 -0
- package/synthesis/pattern-derivation.js +184 -0
- package/synthesis/recommendation-derivation.js +156 -0
- package/synthesis/validate-artifacts.js +46 -0
- package/synthesis/write-artifacts.js +75 -0
- package/validate.js +87 -0
|
@@ -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
|
+
}
|
package/review/index.js
ADDED
|
@@ -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';
|