@dogfood-lab/findings 1.2.2 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/review/index.js CHANGED
@@ -1,6 +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';
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';
@@ -101,13 +101,34 @@ export function performAction(rootDir, params) {
101
101
 
102
102
  // Apply review metadata
103
103
  const now = new Date().toISOString();
104
+
105
+ // H4 / F-stage-a-h4 — auto-populate review.reject_reason whenever the
106
+ // target status is 'rejected'. Pre-amend, the engine only set the field
107
+ // for `action === 'reject'` and silently produced rejected findings on
108
+ // merge / supersede without a structured reason. The H4 schema if/then
109
+ // would then reject those findings at the contract boundary — but the
110
+ // engine was producing them. The fix:
111
+ //
112
+ // - Operator-supplied `params.rejectReason` always wins (override).
113
+ // - `merge` and `supersede` auto-default to `'merged_into_canonical'`
114
+ // since lineage already carries `superseded_by` / `merged_from`.
115
+ // - For `action === 'reject'`, the engine does NOT invent a default —
116
+ // defaulting would erase operator intent. The schema enforces that
117
+ // the field is set; an operator who forgets sees a validation error
118
+ // downstream and learns to pass one. (This is the intentional
119
+ // test-and-tell path in h4-engine-auto-reject-reason.test.js.)
120
+ let effectiveRejectReason = params.rejectReason;
121
+ if (!effectiveRejectReason && toStatus === 'rejected' && (action === 'merge' || action === 'supersede')) {
122
+ effectiveRejectReason = 'merged_into_canonical';
123
+ }
124
+
104
125
  finding.review = {
105
126
  reviewed_by: actor,
106
127
  reviewed_at: now,
107
128
  last_action: action,
108
129
  ...(params.reason ? { decision_reason: params.reason } : {}),
109
130
  ...(params.notes ? { review_notes: params.notes } : {}),
110
- ...(params.rejectReason && action === 'reject' ? { reject_reason: params.rejectReason } : {})
131
+ ...(effectiveRejectReason ? { reject_reason: effectiveRejectReason } : {})
111
132
  };
112
133
 
113
134
  // Update timestamps
@@ -1,79 +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']);
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']);
@@ -1,128 +1,137 @@
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
- }
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, loadAcceptedPatternsWithSkips } from './recommendation-derivation.js';
15
+
16
+ /**
17
+ * Derive doctrine from strong accepted patterns.
18
+ *
19
+ * D2B-001 — return shape carries a `skipped: [{path, error}]` field so the
20
+ * silent-loader signal propagates through the derive API. A torn pattern
21
+ * that WOULD have been considered for doctrine synthesis no longer
22
+ * disappears with no signal. Legacy callers reading `doctrines` / `stats`
23
+ * are unaffected.
24
+ *
25
+ * @param {string} rootDir - dogfood-labs repo root
26
+ * @returns {{ doctrines: Array, skipped: Array<{path: string, error: string}>, stats: { patternsConsidered: number, doctrinesEmitted: number, belowThreshold: number, patternsSkipped: number } }}
27
+ */
28
+ export function deriveDoctrine(rootDir) {
29
+ const { entries, skipped } = loadAcceptedPatternsWithSkips(rootDir);
30
+ const patterns = entries.map(e => e.data);
31
+ const strong = patterns.filter(p =>
32
+ p.pattern_strength === 'strong' || p.pattern_strength === 'portfolio_stable'
33
+ );
34
+
35
+ const doctrines = [];
36
+ let belowThreshold = 0;
37
+
38
+ // Group strong patterns by shared doctrine theme
39
+ const themes = groupByDoctrineTheme(strong);
40
+
41
+ for (const [theme, themePatterns] of themes) {
42
+ // org_wide doctrine requires 2+ patterns
43
+ const maxScope = widestScope(themePatterns);
44
+ if (maxScope === 'org_wide' && themePatterns.length < 2) {
45
+ belowThreshold++;
46
+ continue;
47
+ }
48
+
49
+ doctrines.push(buildDoctrineCandidate(theme, themePatterns));
50
+ }
51
+
52
+ return {
53
+ doctrines,
54
+ skipped, // D2B-001: structured skip list for operator legibility
55
+ stats: {
56
+ patternsConsidered: patterns.length,
57
+ doctrinesEmitted: doctrines.length,
58
+ belowThreshold,
59
+ patternsSkipped: skipped.length
60
+ }
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Group patterns by doctrine theme (shared root cause family).
66
+ */
67
+ function groupByDoctrineTheme(patterns) {
68
+ const themes = new Map();
69
+ for (const p of patterns) {
70
+ const rootCauses = p.dimensions?.root_cause_kinds || [];
71
+ const theme = rootCauses[0] || 'general';
72
+ if (!themes.has(theme)) themes.set(theme, []);
73
+ themes.get(theme).push(p);
74
+ }
75
+ return themes;
76
+ }
77
+
78
+ function widestScope(patterns) {
79
+ const order = ['repo_local', 'surface_local', 'surface_archetype', 'execution_mode', 'org_wide'];
80
+ let widest = 0;
81
+ for (const p of patterns) {
82
+ const idx = order.indexOf(p.transfer_scope);
83
+ if (idx > widest) widest = idx;
84
+ }
85
+ return order[widest];
86
+ }
87
+
88
+ function buildDoctrineCandidate(theme, patterns) {
89
+ const now = new Date().toISOString();
90
+ const issueKinds = [...new Set(patterns.flatMap(p => p.dimensions?.issue_kinds || []))];
91
+ const surfaces = [...new Set(patterns.flatMap(p => p.dimensions?.product_surfaces || []))];
92
+ const scope = widestScope(patterns);
93
+
94
+ const kind = classifyDoctrineKind(issueKinds, theme);
95
+ const slug = `${theme}-${kind}`.replace(/_/g, '-');
96
+
97
+ return {
98
+ schema_version: '1.0.0',
99
+ doctrine_id: `ddoc-${slug}`,
100
+ title: buildDoctrineTitle(theme, issueKinds, surfaces),
101
+ status: 'candidate',
102
+ doctrine_kind: kind,
103
+ statement: buildDoctrineStatement(theme, issueKinds, surfaces),
104
+ rationale: buildDoctrineRationale(patterns, theme),
105
+ based_on_pattern_ids: patterns.map(p => p.pattern_id),
106
+ transfer_scope: scope === 'repo_local' || scope === 'surface_local' ? 'surface_archetype' : scope,
107
+ strength: patterns.length >= 3 ? 'foundational' : 'proven',
108
+ created_at: now,
109
+ updated_at: now
110
+ };
111
+ }
112
+
113
+ function classifyDoctrineKind(issueKinds, theme) {
114
+ if (/evidence/.test(theme) || issueKinds.some(k => /evidence/.test(k))) return 'evidence_law';
115
+ if (/surface|interface/.test(theme)) return 'surface_law';
116
+ if (/policy|calibration/.test(theme)) return 'calibration_law';
117
+ if (/verification|provenance/.test(theme)) return 'verification_law';
118
+ return 'rollout_law';
119
+ }
120
+
121
+ function buildDoctrineTitle(theme, issueKinds, surfaces) {
122
+ const label = theme.replace(/_/g, ' ');
123
+ const surfaceStr = surfaces.length ? surfaces.join(', ') : 'all surfaces';
124
+ return `${label}: verified rule for ${surfaceStr}`;
125
+ }
126
+
127
+ function buildDoctrineStatement(theme, issueKinds, surfaces) {
128
+ const issueLabel = issueKinds.map(k => k.replace(/_/g, ' ')).join(' and ');
129
+ const surfaceStr = surfaces.length ? surfaces.join(', ') : 'all product surfaces';
130
+ return `Verify ${issueLabel} truth before authoring rollout assumptions for ${surfaceStr}. This is a proven recurring failure class — do not skip this step.`;
131
+ }
132
+
133
+ function buildDoctrineRationale(patterns, theme) {
134
+ const findingCount = patterns.reduce((sum, p) => sum + (p.support?.finding_count || 0), 0);
135
+ const repoCount = patterns.reduce((sum, p) => sum + (p.support?.repo_count || 0), 0);
136
+ 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.`;
137
+ }
@@ -1,8 +1,12 @@
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';
1
+ /**
2
+ * Synthesis layer exports.
3
+ */
4
+ export { derivePatterns } from './pattern-derivation.js';
5
+ export { deriveRecommendations, loadAcceptedPatterns, loadAcceptedPatternsWithSkips } from './recommendation-derivation.js';
6
+ export { deriveDoctrine } from './doctrine-derivation.js';
7
+ export { validatePattern, validateRecommendation, validateDoctrine } from './validate-artifacts.js';
8
+ export {
9
+ writePattern, writeRecommendation, writeDoctrine,
10
+ writePatterns, writeRecommendations, writeDoctrines,
11
+ loadPatterns, loadRecommendations, loadDoctrines
12
+ } from './write-artifacts.js';