@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
package/derive/rules.js
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derivation rules for candidate finding extraction.
|
|
3
|
+
*
|
|
4
|
+
* Each rule declares:
|
|
5
|
+
* ruleId — stable identifier
|
|
6
|
+
* description — what it detects
|
|
7
|
+
* applies(ctx) — boolean: does this record trigger the rule?
|
|
8
|
+
* derive(ctx) — CandidateFinding[]: emit zero or more findings
|
|
9
|
+
*
|
|
10
|
+
* ctx shape: { record, rejected, repoSlug }
|
|
11
|
+
* record — full persisted dogfood record JSON
|
|
12
|
+
* rejected — true if the record is in _rejected/
|
|
13
|
+
* repoSlug — e.g. "repo-crawler-mcp"
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// ─── Helpers ────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
function hasRejectionMatching(record, pattern) {
|
|
19
|
+
const reasons = record.verification?.rejection_reasons || [];
|
|
20
|
+
return reasons.some(r => pattern.test(r));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function hasDowngradeReason(record, pattern) {
|
|
24
|
+
const reasons = record.overall_verdict?.downgrade_reasons || [];
|
|
25
|
+
return reasons.some(r => pattern.test(r));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function scenarioSurface(record) {
|
|
29
|
+
return record.scenario_results?.[0]?.product_surface || null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function scenarioMode(record) {
|
|
33
|
+
return record.scenario_results?.[0]?.execution_mode || null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function scenarioId(record) {
|
|
37
|
+
return record.scenario_results?.[0]?.scenario_id || null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function failedSteps(record) {
|
|
41
|
+
const results = record.scenario_results?.[0]?.step_results || [];
|
|
42
|
+
return results.filter(s => s.status === 'fail');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function scenarioVerdict(record) {
|
|
46
|
+
return record.scenario_results?.[0]?.verdict || null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ─── Rule 1: Surface/interface misclassification ────────────
|
|
50
|
+
|
|
51
|
+
const ruleSurfaceMisclassification = {
|
|
52
|
+
ruleId: 'rule-surface-misclassification',
|
|
53
|
+
description: 'Detects when a repo uses an invalid or incorrect product_surface enum value, indicating the runtime interface was misclassified.',
|
|
54
|
+
|
|
55
|
+
applies(ctx) {
|
|
56
|
+
// Fires when schema rejection mentions product_surface enum failure
|
|
57
|
+
return hasRejectionMatching(ctx.record, /product_surface.*must be equal to one of the allowed values/);
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
derive(ctx) {
|
|
61
|
+
const { record, repoSlug } = ctx;
|
|
62
|
+
const badSurface = scenarioSurface(record) || 'unknown';
|
|
63
|
+
const sid = scenarioId(record);
|
|
64
|
+
|
|
65
|
+
return [{
|
|
66
|
+
issue_kind: 'surface_misclassification',
|
|
67
|
+
root_cause_kind: 'surface_misclassification',
|
|
68
|
+
remediation_kind: 'classification_fix',
|
|
69
|
+
transfer_scope: 'surface_archetype',
|
|
70
|
+
journey_stage: 'verification',
|
|
71
|
+
product_surface: mapToValidSurface(badSurface, repoSlug),
|
|
72
|
+
slug: `${repoSlug}-surface-misclassification`,
|
|
73
|
+
title: `Product surface "${badSurface}" rejected — repo requires correct surface enum classification`,
|
|
74
|
+
summary: `The scenario declared product_surface "${badSurface}" which is not a valid enum value. The schema rejected the record. The repo must declare its actual runtime interface using the correct surface vocabulary.`,
|
|
75
|
+
rationale: `Schema rejection reason references product_surface enum failure. The declared value "${badSurface}" is not in the allowed set.`,
|
|
76
|
+
evidence: [
|
|
77
|
+
{ evidence_kind: 'rejection', record_id: record.run_id, note: `Schema rejected product_surface "${badSurface}" as invalid enum value.` },
|
|
78
|
+
...(sid ? [{ evidence_kind: 'scenario_result', record_id: record.run_id, scenario_id: sid, note: 'Scenario used incorrect surface declaration.' }] : [])
|
|
79
|
+
]
|
|
80
|
+
}];
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const VALID_SURFACES = new Set(['cli', 'desktop', 'web', 'api', 'mcp-server', 'npm-package', 'plugin', 'library']);
|
|
85
|
+
|
|
86
|
+
function mapToValidSurface(bad, repoSlug) {
|
|
87
|
+
// Best-effort mapping for emitting a valid finding
|
|
88
|
+
if (VALID_SURFACES.has(bad)) return bad;
|
|
89
|
+
if (bad === 'mcp') return 'mcp-server';
|
|
90
|
+
if (repoSlug.includes('mcp')) return 'mcp-server';
|
|
91
|
+
return 'cli'; // safe fallback
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Get a valid surface from a record, sanitizing if needed. */
|
|
95
|
+
function safeRecordSurface(record, repoSlug) {
|
|
96
|
+
const raw = scenarioSurface(record) || 'cli';
|
|
97
|
+
return mapToValidSurface(raw, repoSlug || '');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ─── Rule 2: Evidence policy mismatch ───────────────────────
|
|
101
|
+
|
|
102
|
+
const ruleEvidencePolicyMismatch = {
|
|
103
|
+
ruleId: 'rule-evidence-policy-mismatch',
|
|
104
|
+
description: 'Detects when a record is rejected because evidence requirements are not met — either insufficient count or missing required kinds.',
|
|
105
|
+
|
|
106
|
+
applies(ctx) {
|
|
107
|
+
return hasRejectionMatching(ctx.record, /required evidence kind|requires \d+ evidence items/);
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
derive(ctx) {
|
|
111
|
+
const { record, repoSlug } = ctx;
|
|
112
|
+
const reasons = record.verification?.rejection_reasons || [];
|
|
113
|
+
const evidenceReasons = reasons.filter(r => /evidence/.test(r));
|
|
114
|
+
const surface = safeRecordSurface(record, repoSlug);
|
|
115
|
+
const sid = scenarioId(record);
|
|
116
|
+
|
|
117
|
+
// Determine if it's overconstraint (too strict) or insufficiency (too weak)
|
|
118
|
+
const isMissingKind = evidenceReasons.some(r => /required evidence kind/.test(r));
|
|
119
|
+
const isCountShort = evidenceReasons.some(r => /requires \d+ evidence items/.test(r));
|
|
120
|
+
|
|
121
|
+
return [{
|
|
122
|
+
issue_kind: isMissingKind ? 'evidence_overconstraint' : 'evidence_insufficiency',
|
|
123
|
+
root_cause_kind: 'policy_overconstraint',
|
|
124
|
+
remediation_kind: 'evidence_requirement_change',
|
|
125
|
+
transfer_scope: 'surface_local',
|
|
126
|
+
journey_stage: 'verification',
|
|
127
|
+
product_surface: surface,
|
|
128
|
+
slug: `${repoSlug}-evidence-policy-mismatch`,
|
|
129
|
+
title: `Evidence requirements rejected the record — policy may need calibration for ${surface} surface`,
|
|
130
|
+
summary: `The record was rejected because evidence did not satisfy policy requirements: ${evidenceReasons.join('; ')}. This may indicate the policy needs calibration to match natural evidence shapes for this surface.`,
|
|
131
|
+
rationale: `Policy rejection reasons explicitly reference evidence requirements: ${evidenceReasons.join('; ')}.`,
|
|
132
|
+
evidence: [
|
|
133
|
+
{ evidence_kind: 'rejection', record_id: record.run_id, note: `Policy rejected due to evidence: ${evidenceReasons.join('; ')}` },
|
|
134
|
+
...(sid ? [{ evidence_kind: 'scenario_result', record_id: record.run_id, scenario_id: sid, note: 'Scenario evidence was insufficient for policy.' }] : [])
|
|
135
|
+
]
|
|
136
|
+
}];
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// ─── Rule 3: Verdict downgrade ──────────────────────────────
|
|
141
|
+
|
|
142
|
+
const ruleVerdictDowngrade = {
|
|
143
|
+
ruleId: 'rule-verdict-downgrade',
|
|
144
|
+
description: 'Detects when the verifier downgrades a proposed pass to fail, indicating the source repo believed it passed but verification disagreed.',
|
|
145
|
+
|
|
146
|
+
applies(ctx) {
|
|
147
|
+
const v = ctx.record.overall_verdict;
|
|
148
|
+
return v?.downgraded === true && v?.proposed === 'pass' && v?.verified !== 'pass';
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
derive(ctx) {
|
|
152
|
+
const { record, repoSlug } = ctx;
|
|
153
|
+
const reasons = record.overall_verdict?.downgrade_reasons || [];
|
|
154
|
+
const rejReasons = record.verification?.rejection_reasons || [];
|
|
155
|
+
const surface = safeRecordSurface(record, repoSlug);
|
|
156
|
+
|
|
157
|
+
// Determine root cause from rejection details
|
|
158
|
+
let issueKind = 'verification_gap';
|
|
159
|
+
let rootCause = 'contract_drift';
|
|
160
|
+
if (!record.verification?.schema_valid) {
|
|
161
|
+
issueKind = 'schema_mismatch';
|
|
162
|
+
rootCause = 'schema_alignment_failure';
|
|
163
|
+
} else if (!record.verification?.policy_valid) {
|
|
164
|
+
issueKind = 'policy_mismatch';
|
|
165
|
+
rootCause = 'policy_overconstraint';
|
|
166
|
+
} else if (!record.verification?.provenance_confirmed) {
|
|
167
|
+
issueKind = 'provenance_gap';
|
|
168
|
+
rootCause = 'missing_precondition';
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return [{
|
|
172
|
+
issue_kind: issueKind,
|
|
173
|
+
root_cause_kind: rootCause,
|
|
174
|
+
remediation_kind: 'verification_fix',
|
|
175
|
+
transfer_scope: 'surface_local',
|
|
176
|
+
journey_stage: 'verification',
|
|
177
|
+
product_surface: surface,
|
|
178
|
+
slug: `${repoSlug}-verdict-downgrade`,
|
|
179
|
+
title: `Proposed pass was downgraded to ${record.overall_verdict.verified} by verifier`,
|
|
180
|
+
summary: `The source repo proposed a pass verdict but the verifier downgraded it to ${record.overall_verdict.verified}. Downgrade reasons: ${reasons.join('; ')}. Rejection details: ${rejReasons.join('; ')}.`,
|
|
181
|
+
rationale: `overall_verdict.downgraded is true, proposed was "pass" but verified is "${record.overall_verdict.verified}". Downgrade reasons: ${reasons.join('; ')}.`,
|
|
182
|
+
evidence: [
|
|
183
|
+
{ evidence_kind: 'record', record_id: record.run_id, note: `Verdict downgraded: proposed=${record.overall_verdict.proposed} verified=${record.overall_verdict.verified}` },
|
|
184
|
+
...(rejReasons.length ? [{ evidence_kind: 'rejection', record_id: record.run_id, note: rejReasons.join('; ') }] : [])
|
|
185
|
+
]
|
|
186
|
+
}];
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// ─── Rule 4: Scenario step failure ──────────────────────────
|
|
191
|
+
|
|
192
|
+
const ruleScenarioStepFailure = {
|
|
193
|
+
ruleId: 'rule-scenario-step-failure',
|
|
194
|
+
description: 'Detects scenarios where specific steps fail, indicating entrypoint, build output, or verification truth issues.',
|
|
195
|
+
|
|
196
|
+
applies(ctx) {
|
|
197
|
+
const failed = failedSteps(ctx.record);
|
|
198
|
+
return failed.length > 0 && scenarioVerdict(ctx.record) === 'fail';
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
derive(ctx) {
|
|
202
|
+
const { record, repoSlug } = ctx;
|
|
203
|
+
const failed = failedSteps(record);
|
|
204
|
+
const surface = safeRecordSurface(record, repoSlug);
|
|
205
|
+
const sid = scenarioId(record);
|
|
206
|
+
const stepIds = failed.map(s => s.step_id);
|
|
207
|
+
|
|
208
|
+
// Classify based on which steps failed
|
|
209
|
+
let issueKind = 'entrypoint_truth';
|
|
210
|
+
let rootCause = 'contract_drift';
|
|
211
|
+
let remediation = 'scenario_change';
|
|
212
|
+
|
|
213
|
+
if (stepIds.some(id => /verify|output|check/.test(id))) {
|
|
214
|
+
issueKind = 'build_output_mismatch';
|
|
215
|
+
rootCause = 'build_config_error';
|
|
216
|
+
remediation = 'build_config_fix';
|
|
217
|
+
}
|
|
218
|
+
if (stepIds.some(id => /flag|arg|param/.test(id))) {
|
|
219
|
+
issueKind = 'flag_contract_mismatch';
|
|
220
|
+
rootCause = 'interface_assumption_error';
|
|
221
|
+
remediation = 'entrypoint_fix';
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return [{
|
|
225
|
+
issue_kind: issueKind,
|
|
226
|
+
root_cause_kind: rootCause,
|
|
227
|
+
remediation_kind: remediation,
|
|
228
|
+
transfer_scope: 'surface_local',
|
|
229
|
+
journey_stage: 'first_run',
|
|
230
|
+
product_surface: surface,
|
|
231
|
+
slug: `${repoSlug}-step-failure-${stepIds[0]}`,
|
|
232
|
+
title: `Scenario step(s) failed: ${stepIds.join(', ')} — ${surface} entrypoint or build output may be wrong`,
|
|
233
|
+
summary: `The scenario failed at step(s): ${stepIds.join(', ')}. This typically indicates the entrypoint, build output path, or invocation contract differs from what the scenario expected. The scenario or build configuration needs correction.`,
|
|
234
|
+
rationale: `scenario_results[0].verdict is "fail" with ${failed.length} failed step(s): ${stepIds.join(', ')}.`,
|
|
235
|
+
evidence: [
|
|
236
|
+
{ evidence_kind: 'scenario_result', record_id: record.run_id, scenario_id: sid, note: `Failed steps: ${stepIds.join(', ')}` },
|
|
237
|
+
{ evidence_kind: 'record', record_id: record.run_id, note: 'Scenario-level verdict is fail.' }
|
|
238
|
+
]
|
|
239
|
+
}];
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
// ─── Rule 5: Blocked scenario ───────────────────────────────
|
|
244
|
+
|
|
245
|
+
const ruleBlockedScenario = {
|
|
246
|
+
ruleId: 'rule-blocked-scenario',
|
|
247
|
+
description: 'Detects scenarios that were blocked entirely, typically indicating a missing precondition or infrastructure gap.',
|
|
248
|
+
|
|
249
|
+
applies(ctx) {
|
|
250
|
+
return ctx.record.scenario_results?.some(s => s.verdict === 'blocked');
|
|
251
|
+
},
|
|
252
|
+
|
|
253
|
+
derive(ctx) {
|
|
254
|
+
const { record, repoSlug } = ctx;
|
|
255
|
+
const blocked = record.scenario_results.filter(s => s.verdict === 'blocked');
|
|
256
|
+
|
|
257
|
+
return blocked.map(scenario => {
|
|
258
|
+
const reason = scenario.blocking_reason || 'No blocking reason provided';
|
|
259
|
+
const surface = mapToValidSurface(scenario.product_surface || 'cli', repoSlug);
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
issue_kind: 'verification_gap',
|
|
263
|
+
root_cause_kind: 'missing_precondition',
|
|
264
|
+
remediation_kind: 'workflow_change',
|
|
265
|
+
transfer_scope: 'surface_local',
|
|
266
|
+
journey_stage: 'first_run',
|
|
267
|
+
product_surface: surface,
|
|
268
|
+
slug: `${repoSlug}-blocked-${scenario.scenario_id}`,
|
|
269
|
+
title: `Scenario "${scenario.scenario_id}" was blocked: ${reason}`,
|
|
270
|
+
summary: `The scenario "${scenario.scenario_id}" could not execute and was marked blocked. Blocking reason: ${reason}. This indicates a missing precondition or infrastructure gap that prevents dogfood verification.`,
|
|
271
|
+
rationale: `scenario_results contains a scenario with verdict "blocked" and blocking_reason: "${reason}".`,
|
|
272
|
+
evidence: [
|
|
273
|
+
{ evidence_kind: 'scenario_result', record_id: record.run_id, scenario_id: scenario.scenario_id, note: `Blocked: ${reason}` },
|
|
274
|
+
{ evidence_kind: 'record', record_id: record.run_id, note: 'Scenario could not execute.' }
|
|
275
|
+
]
|
|
276
|
+
};
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
// ─── Rule 6: Execution mode attestation gap ─────────────────
|
|
282
|
+
|
|
283
|
+
const ruleExecutionModeGap = {
|
|
284
|
+
ruleId: 'rule-execution-mode-gap',
|
|
285
|
+
description: 'Detects human/mixed scenarios missing attestation, or execution mode mismatches with policy expectations.',
|
|
286
|
+
|
|
287
|
+
applies(ctx) {
|
|
288
|
+
return ctx.record.scenario_results?.some(s =>
|
|
289
|
+
(s.execution_mode === 'human' || s.execution_mode === 'mixed') && !s.attested_by
|
|
290
|
+
);
|
|
291
|
+
},
|
|
292
|
+
|
|
293
|
+
derive(ctx) {
|
|
294
|
+
const { record, repoSlug } = ctx;
|
|
295
|
+
const gaps = record.scenario_results.filter(s =>
|
|
296
|
+
(s.execution_mode === 'human' || s.execution_mode === 'mixed') && !s.attested_by
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
return gaps.map(scenario => {
|
|
300
|
+
const surface = mapToValidSurface(scenario.product_surface || 'desktop', repoSlug);
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
issue_kind: 'execution_mode_mismatch',
|
|
304
|
+
root_cause_kind: 'missing_precondition',
|
|
305
|
+
remediation_kind: 'workflow_change',
|
|
306
|
+
transfer_scope: 'execution_mode',
|
|
307
|
+
journey_stage: 'verification',
|
|
308
|
+
product_surface: surface,
|
|
309
|
+
slug: `${repoSlug}-attestation-gap-${scenario.scenario_id}`,
|
|
310
|
+
title: `${scenario.execution_mode} scenario "${scenario.scenario_id}" has no attested_by field`,
|
|
311
|
+
summary: `The scenario uses ${scenario.execution_mode} execution mode but has no attested_by field. Human and mixed scenarios require attestation to prove a human actually exercised the product. The workflow must include the attester identity.`,
|
|
312
|
+
rationale: `scenario_results contains execution_mode "${scenario.execution_mode}" with no attested_by field.`,
|
|
313
|
+
evidence: [
|
|
314
|
+
{ evidence_kind: 'scenario_result', record_id: record.run_id, scenario_id: scenario.scenario_id, note: `${scenario.execution_mode} mode with no attested_by.` },
|
|
315
|
+
{ evidence_kind: 'record', record_id: record.run_id, note: 'Attestation gap detected.' }
|
|
316
|
+
]
|
|
317
|
+
};
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
// ─── Rule 7: Schema rejection (non-surface) ────────────────
|
|
323
|
+
|
|
324
|
+
const ruleSchemaRejection = {
|
|
325
|
+
ruleId: 'rule-schema-rejection',
|
|
326
|
+
description: 'Detects schema validation failures not covered by surface misclassification — general contract drift between producer and consumer.',
|
|
327
|
+
|
|
328
|
+
applies(ctx) {
|
|
329
|
+
if (ctx.record.verification?.schema_valid !== false) return false;
|
|
330
|
+
// Only fire if not already covered by surface misclassification
|
|
331
|
+
const reasons = ctx.record.verification?.rejection_reasons || [];
|
|
332
|
+
const hasSurfaceIssue = reasons.some(r => /product_surface/.test(r));
|
|
333
|
+
const hasSchemaIssue = reasons.some(r => /^schema:/.test(r));
|
|
334
|
+
return hasSchemaIssue && !hasSurfaceIssue;
|
|
335
|
+
},
|
|
336
|
+
|
|
337
|
+
derive(ctx) {
|
|
338
|
+
const { record, repoSlug } = ctx;
|
|
339
|
+
const reasons = record.verification?.rejection_reasons?.filter(r => /^schema:/.test(r)) || [];
|
|
340
|
+
const surface = safeRecordSurface(record, repoSlug);
|
|
341
|
+
|
|
342
|
+
return [{
|
|
343
|
+
issue_kind: 'schema_mismatch',
|
|
344
|
+
root_cause_kind: 'schema_alignment_failure',
|
|
345
|
+
remediation_kind: 'schema_fix',
|
|
346
|
+
transfer_scope: 'org_wide',
|
|
347
|
+
journey_stage: 'verification',
|
|
348
|
+
product_surface: surface,
|
|
349
|
+
slug: `${repoSlug}-schema-rejection`,
|
|
350
|
+
title: `Schema validation failed — producer/consumer contract drift detected`,
|
|
351
|
+
summary: `The record was rejected due to schema validation failures: ${reasons.join('; ')}. This indicates the submission does not conform to the expected record contract. The source workflow or submission builder needs updating.`,
|
|
352
|
+
rationale: `verification.schema_valid is false with schema rejection reasons: ${reasons.join('; ')}.`,
|
|
353
|
+
evidence: [
|
|
354
|
+
{ evidence_kind: 'rejection', record_id: record.run_id, note: `Schema failures: ${reasons.join('; ')}` },
|
|
355
|
+
{ evidence_kind: 'record', record_id: record.run_id, note: 'Schema validation returned false.' }
|
|
356
|
+
]
|
|
357
|
+
}];
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
// ─── Rule 8: Policy rejection (non-evidence) ────────────────
|
|
362
|
+
|
|
363
|
+
const rulePolicyRejection = {
|
|
364
|
+
ruleId: 'rule-policy-rejection',
|
|
365
|
+
description: 'Detects policy validation failures not covered by evidence mismatch — general policy misalignment.',
|
|
366
|
+
|
|
367
|
+
applies(ctx) {
|
|
368
|
+
if (ctx.record.verification?.policy_valid !== false) return false;
|
|
369
|
+
// Only fire if not already covered by evidence policy mismatch
|
|
370
|
+
const reasons = ctx.record.verification?.rejection_reasons || [];
|
|
371
|
+
const hasEvidenceIssue = reasons.some(r => /evidence/.test(r));
|
|
372
|
+
const hasPolicyIssue = reasons.some(r => /^policy:/.test(r));
|
|
373
|
+
return hasPolicyIssue && !hasEvidenceIssue;
|
|
374
|
+
},
|
|
375
|
+
|
|
376
|
+
derive(ctx) {
|
|
377
|
+
const { record, repoSlug } = ctx;
|
|
378
|
+
const reasons = record.verification?.rejection_reasons?.filter(r => /^policy:/.test(r)) || [];
|
|
379
|
+
const surface = safeRecordSurface(record, repoSlug);
|
|
380
|
+
|
|
381
|
+
return [{
|
|
382
|
+
issue_kind: 'policy_mismatch',
|
|
383
|
+
root_cause_kind: 'policy_underconstraint',
|
|
384
|
+
remediation_kind: 'policy_calibration',
|
|
385
|
+
transfer_scope: 'surface_local',
|
|
386
|
+
journey_stage: 'verification',
|
|
387
|
+
product_surface: surface,
|
|
388
|
+
slug: `${repoSlug}-policy-rejection`,
|
|
389
|
+
title: `Policy validation failed — repo does not satisfy surface policy requirements`,
|
|
390
|
+
summary: `The record was rejected due to policy failures: ${reasons.join('; ')}. The repo policy or the scenario shape needs calibration to match actual product behavior.`,
|
|
391
|
+
rationale: `verification.policy_valid is false with policy rejection reasons: ${reasons.join('; ')}.`,
|
|
392
|
+
evidence: [
|
|
393
|
+
{ evidence_kind: 'rejection', record_id: record.run_id, note: `Policy failures: ${reasons.join('; ')}` },
|
|
394
|
+
{ evidence_kind: 'record', record_id: record.run_id, note: 'Policy validation returned false.' }
|
|
395
|
+
]
|
|
396
|
+
}];
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
// ─── Export all rules ───────────────────────────────────────
|
|
401
|
+
|
|
402
|
+
export const RULES = [
|
|
403
|
+
ruleSurfaceMisclassification,
|
|
404
|
+
ruleEvidencePolicyMismatch,
|
|
405
|
+
ruleVerdictDowngrade,
|
|
406
|
+
ruleScenarioStepFailure,
|
|
407
|
+
ruleBlockedScenario,
|
|
408
|
+
ruleExecutionModeGap,
|
|
409
|
+
ruleSchemaRejection,
|
|
410
|
+
rulePolicyRejection
|
|
411
|
+
];
|
|
412
|
+
|
|
413
|
+
export function getRuleById(ruleId) {
|
|
414
|
+
return RULES.find(r => r.ruleId === ruleId) || null;
|
|
415
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write derived candidate findings to disk as YAML files.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { mkdirSync, existsSync } from 'node:fs';
|
|
6
|
+
import { resolve, dirname } from 'node:path';
|
|
7
|
+
import yaml from 'js-yaml';
|
|
8
|
+
|
|
9
|
+
import { atomicWriteFileSync } from '../lib/atomic-write.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Write a candidate finding to its canonical location.
|
|
13
|
+
*
|
|
14
|
+
* findings/<org>/<repo>/<finding_id>.yaml
|
|
15
|
+
*
|
|
16
|
+
* @param {string} rootDir - dogfood-labs repo root.
|
|
17
|
+
* @param {object} finding - Schema-valid candidate finding object.
|
|
18
|
+
* @returns {string} - Path written.
|
|
19
|
+
*/
|
|
20
|
+
export function writeFinding(rootDir, finding) {
|
|
21
|
+
const [org, repo] = (finding.repo || '').split('/');
|
|
22
|
+
if (!org || !repo) throw new Error(`Invalid repo in finding: ${finding.repo}`);
|
|
23
|
+
|
|
24
|
+
const dir = resolve(rootDir, 'findings', org, repo);
|
|
25
|
+
mkdirSync(dir, { recursive: true });
|
|
26
|
+
|
|
27
|
+
const filePath = resolve(dir, `${finding.finding_id}.yaml`);
|
|
28
|
+
|
|
29
|
+
// Strip undefined values for clean YAML
|
|
30
|
+
const clean = JSON.parse(JSON.stringify(finding));
|
|
31
|
+
const yamlStr = yaml.dump(clean, {
|
|
32
|
+
lineWidth: 120,
|
|
33
|
+
noRefs: true,
|
|
34
|
+
quotingType: '"',
|
|
35
|
+
forceQuotes: false
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
atomicWriteFileSync(filePath, yamlStr);
|
|
39
|
+
return filePath;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Write multiple findings and return write stats.
|
|
44
|
+
*
|
|
45
|
+
* @param {string} rootDir
|
|
46
|
+
* @param {Array} findings
|
|
47
|
+
* @returns {{ written: string[], errors: Array<{ findingId: string, error: string }> }}
|
|
48
|
+
*/
|
|
49
|
+
export function writeFindings(rootDir, findings) {
|
|
50
|
+
const written = [];
|
|
51
|
+
const errors = [];
|
|
52
|
+
|
|
53
|
+
for (const f of findings) {
|
|
54
|
+
try {
|
|
55
|
+
const path = writeFinding(rootDir, f);
|
|
56
|
+
written.push(path);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
errors.push({ findingId: f.finding_id, error: err.message });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { written, errors };
|
|
63
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @dogfood-labs/findings
|
|
3
|
+
*
|
|
4
|
+
* Finding contract spine for dogfood-labs.
|
|
5
|
+
* The fourth contract alongside record, scenario, and policy.
|
|
6
|
+
*
|
|
7
|
+
* Exports: validation, reading, listing, filtering, duplicate detection.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export { parseFinding, validateFinding, validateFindingFile } from './validate.js';
|
|
11
|
+
export { discoverFindings, discoverFixtures, loadFindings, findById, filterFindings, findDuplicates } from './reader.js';
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic file write — temp+rename so torn writes never silently drop a file
|
|
3
|
+
* from listings. `rename` is atomic on POSIX and Windows: a concurrent reader
|
|
4
|
+
* sees either the old contents or the new contents — never a half-written file.
|
|
5
|
+
*
|
|
6
|
+
* Mirrors the pattern in `packages/ingest/persist.js` and
|
|
7
|
+
* `packages/ingest/rebuild-indexes.js`. Originally extracted from
|
|
8
|
+
* `packages/findings/review/event-log.js` (wave 9). Used by the artifact
|
|
9
|
+
* writers in `derive/`, `synthesis/`, and `review/` to close the sibling-fix
|
|
10
|
+
* gap from F-721047-010 — every loader in this pipeline has a try/empty-catch,
|
|
11
|
+
* so torn YAML disappears from listings without surfacing an error.
|
|
12
|
+
*
|
|
13
|
+
* Concurrency caveat: this protects against partial-file corruption only.
|
|
14
|
+
* Two simultaneous writers can still race read-then-write at the caller level.
|
|
15
|
+
* Callers needing read-modify-write atomicity must serialize externally.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import { randomBytes } from 'node:crypto';
|
|
20
|
+
import { renameWithRetry } from './rename-with-retry.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Atomically write `content` to `path`.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} path - Final destination path.
|
|
26
|
+
* @param {string} content - File contents.
|
|
27
|
+
* @param {BufferEncoding} [encoding='utf-8'] - Write encoding.
|
|
28
|
+
* @returns {string} - The path that was written.
|
|
29
|
+
*/
|
|
30
|
+
export function atomicWriteFileSync(path, content, encoding = 'utf-8') {
|
|
31
|
+
const tmpSuffix = randomBytes(4).toString('hex');
|
|
32
|
+
const tmpPath = `${path}.${tmpSuffix}.tmp`;
|
|
33
|
+
try {
|
|
34
|
+
// Indirect via `fs.*` (not destructured imports) so test mocks of
|
|
35
|
+
// `fs.writeFileSync` reach this code path. Behavior is identical.
|
|
36
|
+
fs.writeFileSync(tmpPath, content, encoding);
|
|
37
|
+
// Windows-only: rename can transiently fail with EPERM/EBUSY when AV
|
|
38
|
+
// or Search Indexer holds a handle on the just-written temp file.
|
|
39
|
+
// renameWithRetry uses bounded exponential backoff; behavior on POSIX
|
|
40
|
+
// is identical to renameSync (the retry never fires).
|
|
41
|
+
renameWithRetry(tmpPath, path);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
try { fs.unlinkSync(tmpPath); } catch { /* tmp may not exist */ }
|
|
44
|
+
throw err;
|
|
45
|
+
}
|
|
46
|
+
return path;
|
|
47
|
+
}
|