@dogfood-lab/findings 1.9.0 → 1.10.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/advise/query.js +35 -6
- package/derive/ids.js +20 -2
- package/derive/load-records.js +11 -1
- package/derive/rules.js +115 -8
- package/lib/file-lock.js +52 -13
- package/lib/rename-with-retry.js +27 -2
- package/package.json +2 -1
- package/review/review-engine.js +22 -5
- package/review/transitions.js +111 -2
- package/synthesis/apply-recommendation.js +68 -5
package/advise/query.js
CHANGED
|
@@ -113,6 +113,26 @@ export function queryRecommendations(rootDir, scope = {}) {
|
|
|
113
113
|
|
|
114
114
|
/**
|
|
115
115
|
* Query accepted doctrine by scope.
|
|
116
|
+
*
|
|
117
|
+
* F-d022c023: the surface-scoping filter below used to end in a bare `true`
|
|
118
|
+
* — since doctrine.transfer_scope is a closed 3-value enum
|
|
119
|
+
* (packages/schemas/src/json/dogfood-doctrine.schema.json: org_wide,
|
|
120
|
+
* execution_mode, surface_archetype) and the first two branches already
|
|
121
|
+
* matched two of the three values explicitly, the trailing `true` made the
|
|
122
|
+
* WHOLE expression unconditionally true for every doctrine record — a
|
|
123
|
+
* structural no-op covering 100% of the enum, not a rare edge case.
|
|
124
|
+
* Replacing that `true` with a third literal `transfer_scope ===
|
|
125
|
+
* 'surface_archetype'` comparison would be an equally-complete no-op (the
|
|
126
|
+
* three literal branches would still exhaust the closed enum and always be
|
|
127
|
+
* true) — so it does NOT satisfy this fix; it would just move the dead code
|
|
128
|
+
* one line over. doctrine itself carries NO per-record surface field to
|
|
129
|
+
* compare against (the schema's `additionalProperties: false` rules one out
|
|
130
|
+
* without a schema/version bump), so a REAL surface_archetype check has to
|
|
131
|
+
* reach through `based_on_pattern_ids` to the patterns' own
|
|
132
|
+
* `dimensions.product_surfaces` — the same signal `queryPatterns` above
|
|
133
|
+
* already uses, and the literal thing this function's own (correct) comment
|
|
134
|
+
* about org_wide/execution_mode always says: "surface_archetype applies if
|
|
135
|
+
* pattern surfaces match."
|
|
116
136
|
*/
|
|
117
137
|
export function queryDoctrine(rootDir, scope = {}) {
|
|
118
138
|
const all = loadDoctrines(rootDir);
|
|
@@ -121,13 +141,22 @@ export function queryDoctrine(rootDir, scope = {}) {
|
|
|
121
141
|
let results = all.filter(d => d.status === 'accepted');
|
|
122
142
|
|
|
123
143
|
if (scope.surface) {
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
144
|
+
// org_wide and execution_mode are surface-agnostic BY DEFINITION (their
|
|
145
|
+
// whole point is applying beyond any single surface), so they always
|
|
146
|
+
// pass once ANY surface is requested — that part of the original intent
|
|
147
|
+
// was correct and is unchanged. surface_archetype is the one scope tied
|
|
148
|
+
// to a specific surface lineage: does the requested surface appear in
|
|
149
|
+
// the dimensions.product_surfaces of AT LEAST ONE pattern this doctrine
|
|
150
|
+
// is based_on_pattern_ids-derived from?
|
|
151
|
+
const patternSurfacesById = new Map(
|
|
152
|
+
loadPatterns(rootDir).map(p => [p.pattern_id, p.dimensions?.product_surfaces || []])
|
|
130
153
|
);
|
|
154
|
+
results = results.filter(d => {
|
|
155
|
+
if (d.transfer_scope === 'org_wide' || d.transfer_scope === 'execution_mode') return true;
|
|
156
|
+
return (d.based_on_pattern_ids || []).some(
|
|
157
|
+
pid => patternSurfacesById.get(pid)?.includes(scope.surface)
|
|
158
|
+
);
|
|
159
|
+
});
|
|
131
160
|
}
|
|
132
161
|
|
|
133
162
|
results.sort((a, b) => {
|
package/derive/ids.js
CHANGED
|
@@ -38,17 +38,35 @@ export function generateFindingId(repoSlug, lessonSlug) {
|
|
|
38
38
|
* Compute a dedupe key for collision detection.
|
|
39
39
|
* Two findings with the same dedupe key are considered the same lesson.
|
|
40
40
|
*
|
|
41
|
+
* F-112c88a4: defense-in-depth sibling of generateFindingId's own
|
|
42
|
+
* boundary-collision fix (findings-A-002, above). A plain `'::'.join()` here
|
|
43
|
+
* would let a delimiter-shaped VALUE in one field forge a false boundary
|
|
44
|
+
* with its neighbor — e.g. (issue_kind='x::y', root_cause_kind='z') and
|
|
45
|
+
* (issue_kind='x', root_cause_kind='y::z') both flatten to the same joined
|
|
46
|
+
* string. Routed through the same NUL-delimited boundaryHash() helper
|
|
47
|
+
* generateFindingId uses, so no component value can forge a boundary
|
|
48
|
+
* regardless of what it contains.
|
|
49
|
+
*
|
|
50
|
+
* Today's five fields are each enum- or pattern-constrained upstream (see
|
|
51
|
+
* dogfood-finding.schema.json) such that none can actually contain '::', so
|
|
52
|
+
* this specific collision is not reachable through the intended pipeline —
|
|
53
|
+
* but that safety lives entirely in the CALLER's schema, not in this
|
|
54
|
+
* function's own construction. boundaryHash() makes computeDedupeKey safe on
|
|
55
|
+
* its own terms, so a future caller (or a future schema relaxation on any of
|
|
56
|
+
* these fields) cannot unknowingly reopen the collision generateFindingId
|
|
57
|
+
* was explicitly fixed to close.
|
|
58
|
+
*
|
|
41
59
|
* @param {{ repo: string, issue_kind: string, root_cause_kind: string, journey_stage: string, slug: string }} fields
|
|
42
60
|
* @returns {string}
|
|
43
61
|
*/
|
|
44
62
|
export function computeDedupeKey(fields) {
|
|
45
|
-
return [
|
|
63
|
+
return boundaryHash([
|
|
46
64
|
fields.repo,
|
|
47
65
|
fields.issue_kind,
|
|
48
66
|
fields.root_cause_kind,
|
|
49
67
|
fields.journey_stage,
|
|
50
68
|
fields.slug
|
|
51
|
-
]
|
|
69
|
+
]);
|
|
52
70
|
}
|
|
53
71
|
|
|
54
72
|
/**
|
package/derive/load-records.js
CHANGED
|
@@ -33,7 +33,17 @@ export function loadRecordsForRepo(rootDir, repoKey) {
|
|
|
33
33
|
* @returns {{ entries: Array<{ record: object, rejected: boolean, path: string }>, skipped: Array<{ path: string, error: string }> }}
|
|
34
34
|
*/
|
|
35
35
|
export function loadRecordsForRepoWithSkips(rootDir, repoKey) {
|
|
36
|
-
|
|
36
|
+
// F-60a31e29: two-segment contract (F-916867-005 family) — mirrors
|
|
37
|
+
// persist.js:51 and load-context.js:138/333, which both enforce
|
|
38
|
+
// `segments.length !== 2` ahead of the traversal guard below. This site
|
|
39
|
+
// was the odd one out: a bare 2-way destructure silently DROPPED any
|
|
40
|
+
// third segment, so `loadRecordsForRepoWithSkips(root, 'a/b/c')` silently
|
|
41
|
+
// loaded records for the DIFFERENT repo 'a/b' instead of failing closed.
|
|
42
|
+
const segments = repoKey.split('/');
|
|
43
|
+
if (segments.length !== 2) {
|
|
44
|
+
return { entries: [], skipped: [] };
|
|
45
|
+
}
|
|
46
|
+
const [org, repo] = segments;
|
|
37
47
|
// Path-traversal guard: F-916867-005. Mirrors persist.js + load-context.js
|
|
38
48
|
// via the central helper at @dogfood-lab/ingest/lib/unsafe-segment.js.
|
|
39
49
|
// A malformed repoKey (`..` or path-separator) would otherwise resolve
|
package/derive/rules.js
CHANGED
|
@@ -13,6 +13,21 @@
|
|
|
13
13
|
* repoSlug — e.g. "repo-crawler-mcp"
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
// F-e1d45d27 (wave 12, LOW): ruleSchemaRejection/rulePolicyRejection below used
|
|
17
|
+
// to hand-roll `/^schema:/` / `/^policy:/` against raw rejection_reasons
|
|
18
|
+
// strings — exactly the drift parse-rejection.js's own file header exists to
|
|
19
|
+
// end ("every operator discriminated failure class with hand-rolled
|
|
20
|
+
// .startsWith() chains ... a fresh drift source the moment a prefix is
|
|
21
|
+
// added"). `@dogfood-lab/verify` was already a TRANSITIVE dependency here (via
|
|
22
|
+
// @dogfood-lab/ingest) and depends on nothing beyond @dogfood-lab/schemas +
|
|
23
|
+
// js-yaml, so importing it directly adds no new workspace cycle. Routing
|
|
24
|
+
// through the authoritative classifier (`.prefix === 'schema:'` / `'policy:'`)
|
|
25
|
+
// is behaviorally identical today — see derive.test.js's "Regression: rules.js
|
|
26
|
+
// schema:/policy: routes through parseRejectionReason (F-e1d45d27)" describe
|
|
27
|
+
// block for the differential proof — and stays correct automatically if the
|
|
28
|
+
// taxonomy ever grows or reorders.
|
|
29
|
+
import { parseRejectionReason } from '@dogfood-lab/verify';
|
|
30
|
+
|
|
16
31
|
// ─── Helpers ────────────────────────────────────────────────
|
|
17
32
|
|
|
18
33
|
function hasRejectionMatching(record, pattern) {
|
|
@@ -46,6 +61,33 @@ function scenarioVerdict(record) {
|
|
|
46
61
|
return record.scenario_results?.[0]?.verdict || null;
|
|
47
62
|
}
|
|
48
63
|
|
|
64
|
+
// F-88fb37ff: a scenario's step_results is the ONLY corroborating evidence
|
|
65
|
+
// for a self-reported "blocked" (or "fail") verdict — a verifier fix closed
|
|
66
|
+
// this same gap in @dogfood-lab/verify's validateStepResults, but the derive
|
|
67
|
+
// pipeline reads records from BOTH records/ and records/_rejected/ (see
|
|
68
|
+
// load-records.js) and never consults verification.status, so a
|
|
69
|
+
// self-contradictory record still reaches every rule here with
|
|
70
|
+
// scenario_results untouched. ruleScenarioStepFailure already gates on
|
|
71
|
+
// failedSteps(record).length > 0 for the "fail" verdict; this mirrors that
|
|
72
|
+
// same evidentiary floor for a single scenario object (rule-blocked-scenario
|
|
73
|
+
// iterates scenarios directly rather than through the record-level,
|
|
74
|
+
// index-0-only helpers above).
|
|
75
|
+
//
|
|
76
|
+
// F-e42e8f80 (wave 20, amends F-88fb37ff): renamed from hasFailOrBlockedStep
|
|
77
|
+
// and its condition widened — the original "at least one step actively
|
|
78
|
+
// fail/blocked" bar had the identical blind spot the verifier-level fix
|
|
79
|
+
// (validateStepResults/validateRequiredSteps) closed: a genuinely blocked
|
|
80
|
+
// scenario whose steps honestly report 'skip' (never ran) has ZERO
|
|
81
|
+
// fail/blocked steps, so the old bar silently dropped the
|
|
82
|
+
// 'verification_gap'/'missing_precondition' finding for exactly the class of
|
|
83
|
+
// record most likely to deserve it. Only an ACTIVE 'pass' is a contradiction
|
|
84
|
+
// of a blocked verdict; 'skip'/'partial' are neutral, like a step that never
|
|
85
|
+
// ran. Mirrors the verifier-level fix's "not all pass" bar exactly.
|
|
86
|
+
function hasNonPassStepEvidence(scenario) {
|
|
87
|
+
const results = scenario.step_results || [];
|
|
88
|
+
return results.some(s => s != null && s.status !== 'pass');
|
|
89
|
+
}
|
|
90
|
+
|
|
49
91
|
// ─── Rule 1: Surface/interface misclassification ────────────
|
|
50
92
|
|
|
51
93
|
const ruleSurfaceMisclassification = {
|
|
@@ -228,7 +270,7 @@ const ruleScenarioStepFailure = {
|
|
|
228
270
|
transfer_scope: 'surface_local',
|
|
229
271
|
journey_stage: 'first_run',
|
|
230
272
|
product_surface: surface,
|
|
231
|
-
slug: `${repoSlug}-step-failure-${stepIds[0]}`,
|
|
273
|
+
slug: `${repoSlug}-step-failure-${sid}-${stepIds[0]}`,
|
|
232
274
|
title: `Scenario step(s) failed: ${stepIds.join(', ')} — ${surface} entrypoint or build output may be wrong`,
|
|
233
275
|
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
276
|
rationale: `scenario_results[0].verdict is "fail" with ${failed.length} failed step(s): ${stepIds.join(', ')}.`,
|
|
@@ -247,12 +289,16 @@ const ruleBlockedScenario = {
|
|
|
247
289
|
description: 'Detects scenarios that were blocked entirely, typically indicating a missing precondition or infrastructure gap.',
|
|
248
290
|
|
|
249
291
|
applies(ctx) {
|
|
250
|
-
|
|
292
|
+
// F-88fb37ff: require step evidence backing the self-reported verdict —
|
|
293
|
+
// otherwise a self-contradictory record (blocked verdict, every step
|
|
294
|
+
// reports pass) synthesizes a plausible-sounding but false
|
|
295
|
+
// "infrastructure gap" finding the record's own evidence contradicts.
|
|
296
|
+
return ctx.record.scenario_results?.some(s => s.verdict === 'blocked' && hasNonPassStepEvidence(s));
|
|
251
297
|
},
|
|
252
298
|
|
|
253
299
|
derive(ctx) {
|
|
254
300
|
const { record, repoSlug } = ctx;
|
|
255
|
-
const blocked = record.scenario_results.filter(s => s.verdict === 'blocked');
|
|
301
|
+
const blocked = record.scenario_results.filter(s => s.verdict === 'blocked' && hasNonPassStepEvidence(s));
|
|
256
302
|
|
|
257
303
|
return blocked.map(scenario => {
|
|
258
304
|
const reason = scenario.blocking_reason || 'No blocking reason provided';
|
|
@@ -330,13 +376,13 @@ const ruleSchemaRejection = {
|
|
|
330
376
|
// Only fire if not already covered by surface misclassification
|
|
331
377
|
const reasons = ctx.record.verification?.rejection_reasons || [];
|
|
332
378
|
const hasSurfaceIssue = reasons.some(r => /product_surface/.test(r));
|
|
333
|
-
const hasSchemaIssue = reasons.some(r =>
|
|
379
|
+
const hasSchemaIssue = reasons.some(r => parseRejectionReason(r).prefix === 'schema:');
|
|
334
380
|
return hasSchemaIssue && !hasSurfaceIssue;
|
|
335
381
|
},
|
|
336
382
|
|
|
337
383
|
derive(ctx) {
|
|
338
384
|
const { record, repoSlug } = ctx;
|
|
339
|
-
const reasons = record.verification?.rejection_reasons?.filter(r =>
|
|
385
|
+
const reasons = record.verification?.rejection_reasons?.filter(r => parseRejectionReason(r).prefix === 'schema:') || [];
|
|
340
386
|
const surface = safeRecordSurface(record, repoSlug);
|
|
341
387
|
|
|
342
388
|
return [{
|
|
@@ -369,13 +415,13 @@ const rulePolicyRejection = {
|
|
|
369
415
|
// Only fire if not already covered by evidence policy mismatch
|
|
370
416
|
const reasons = ctx.record.verification?.rejection_reasons || [];
|
|
371
417
|
const hasEvidenceIssue = reasons.some(r => /evidence/.test(r));
|
|
372
|
-
const hasPolicyIssue = reasons.some(r =>
|
|
418
|
+
const hasPolicyIssue = reasons.some(r => parseRejectionReason(r).prefix === 'policy:');
|
|
373
419
|
return hasPolicyIssue && !hasEvidenceIssue;
|
|
374
420
|
},
|
|
375
421
|
|
|
376
422
|
derive(ctx) {
|
|
377
423
|
const { record, repoSlug } = ctx;
|
|
378
|
-
const reasons = record.verification?.rejection_reasons?.filter(r =>
|
|
424
|
+
const reasons = record.verification?.rejection_reasons?.filter(r => parseRejectionReason(r).prefix === 'policy:') || [];
|
|
379
425
|
const surface = safeRecordSurface(record, repoSlug);
|
|
380
426
|
|
|
381
427
|
return [{
|
|
@@ -397,6 +443,66 @@ const rulePolicyRejection = {
|
|
|
397
443
|
}
|
|
398
444
|
};
|
|
399
445
|
|
|
446
|
+
// ─── Rule 9: Partial scenario with contradicting evidence ───
|
|
447
|
+
|
|
448
|
+
const rulePartialScenarioEvidence = {
|
|
449
|
+
ruleId: 'rule-partial-scenario-evidence',
|
|
450
|
+
description: 'Detects scenarios with a self-reported "partial" verdict backed by non-pass step evidence — the derivation-layer half of the F-cc198701 fix, so a partial verdict is no longer invisible to every rule in this file.',
|
|
451
|
+
|
|
452
|
+
applies(ctx) {
|
|
453
|
+
// F-cc198701 (wave 22, confirming audit of F-88fb37ff/F-e42e8f80): this
|
|
454
|
+
// rule's two closest siblings each enumerate exactly ONE of the four
|
|
455
|
+
// legal scenario_results[].verdict enum values (dogfood-record-
|
|
456
|
+
// submission.schema.json: ["pass","fail","blocked","partial"]) —
|
|
457
|
+
// ruleBlockedScenario checks only 'blocked' (line ~296 above) and
|
|
458
|
+
// ruleScenarioStepFailure checks only 'fail' (line ~240 above). Neither
|
|
459
|
+
// ever checks 'partial', so a 'partial'-verdict scenario_result was
|
|
460
|
+
// invisible to EVERY rule in this file regardless of its step evidence:
|
|
461
|
+
// a self-contradictory record (verdict:'partial' with every step actively
|
|
462
|
+
// 'fail' — strictly worse evidence than what the other two rules already
|
|
463
|
+
// catch) generated zero corrective/diagnostic finding anywhere in the
|
|
464
|
+
// intelligence layer, on top of being a false-rejection gap at the
|
|
465
|
+
// verifier layer (see steps.js's matching F-cc198701 fix, same wave).
|
|
466
|
+
//
|
|
467
|
+
// A dedicated sibling rule — rather than widening ruleBlockedScenario's
|
|
468
|
+
// own condition, which the finding's fix note offered as an alternative —
|
|
469
|
+
// keeps that rule's "was blocked: <reason>" title/summary text honest:
|
|
470
|
+
// blocking_reason is schema-documented as "Required when verdict is
|
|
471
|
+
// blocked," so reusing 'blocked' wording for a genuinely 'partial'
|
|
472
|
+
// scenario would misdescribe it to a human reviewer.
|
|
473
|
+
return ctx.record.scenario_results?.some(s => s.verdict === 'partial' && hasNonPassStepEvidence(s));
|
|
474
|
+
},
|
|
475
|
+
|
|
476
|
+
derive(ctx) {
|
|
477
|
+
const { record, repoSlug } = ctx;
|
|
478
|
+
const partial = record.scenario_results.filter(s => s.verdict === 'partial' && hasNonPassStepEvidence(s));
|
|
479
|
+
|
|
480
|
+
return partial.map(scenario => {
|
|
481
|
+
const surface = mapToValidSurface(scenario.product_surface || 'cli', repoSlug);
|
|
482
|
+
const nonPassIds = (scenario.step_results || [])
|
|
483
|
+
.filter(s => s != null && s.status !== 'pass')
|
|
484
|
+
.map(s => s.step_id);
|
|
485
|
+
|
|
486
|
+
return {
|
|
487
|
+
issue_kind: 'verification_gap',
|
|
488
|
+
root_cause_kind: 'contract_drift',
|
|
489
|
+
remediation_kind: 'scenario_change',
|
|
490
|
+
transfer_scope: 'surface_local',
|
|
491
|
+
journey_stage: 'verification',
|
|
492
|
+
product_surface: surface,
|
|
493
|
+
slug: `${repoSlug}-partial-${scenario.scenario_id}`,
|
|
494
|
+
title: `Scenario "${scenario.scenario_id}" reported "partial" backed by non-pass step evidence`,
|
|
495
|
+
summary: `The scenario "${scenario.scenario_id}" self-reported a "partial" verdict, corroborated by non-pass step(s): ${nonPassIds.join(', ')}. A partial verdict backed by non-pass evidence may indicate an incomplete run, a scenario whose success criteria need review, or a genuinely partial pass worth surfacing to the portfolio layer.`,
|
|
496
|
+
rationale: `scenario_results contains a scenario with verdict "partial" and non-pass step evidence: [${nonPassIds.join(', ')}].`,
|
|
497
|
+
evidence: [
|
|
498
|
+
{ evidence_kind: 'scenario_result', record_id: record.run_id, scenario_id: scenario.scenario_id, note: `Partial verdict corroborated by non-pass steps: ${nonPassIds.join(', ')}` },
|
|
499
|
+
{ evidence_kind: 'record', record_id: record.run_id, note: 'Scenario verdict is partial with corroborating non-pass step evidence.' }
|
|
500
|
+
]
|
|
501
|
+
};
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
|
|
400
506
|
// ─── Export all rules ───────────────────────────────────────
|
|
401
507
|
|
|
402
508
|
export const RULES = [
|
|
@@ -407,7 +513,8 @@ export const RULES = [
|
|
|
407
513
|
ruleBlockedScenario,
|
|
408
514
|
ruleExecutionModeGap,
|
|
409
515
|
ruleSchemaRejection,
|
|
410
|
-
rulePolicyRejection
|
|
516
|
+
rulePolicyRejection,
|
|
517
|
+
rulePartialScenarioEvidence
|
|
411
518
|
];
|
|
412
519
|
|
|
413
520
|
export function getRuleById(ruleId) {
|
package/lib/file-lock.js
CHANGED
|
@@ -134,10 +134,11 @@ function atomicCreateLock(lockPath) {
|
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
/**
|
|
137
|
-
* Attempt to acquire an exclusive file-lock on `lockPath`. Returns
|
|
138
|
-
* the lock was acquired;
|
|
139
|
-
* by a process whose lock file is
|
|
140
|
-
* with bounded mtime patience — see
|
|
137
|
+
* Attempt to acquire an exclusive file-lock on `lockPath`. Returns
|
|
138
|
+
* `{ acquired: true }` if the lock was acquired; `{ acquired: false, reason }`
|
|
139
|
+
* if it is held by another live process OR by a process whose lock file is
|
|
140
|
+
* unreadable / pid-empty (treated as live with bounded mtime patience — see
|
|
141
|
+
* below).
|
|
141
142
|
*
|
|
142
143
|
* Stale recovery: a holder's PID file remains until its release `unlink`
|
|
143
144
|
* runs. If the holder PROCESS is gone (process.kill ESRCH), the lock is
|
|
@@ -149,14 +150,23 @@ function atomicCreateLock(lockPath) {
|
|
|
149
150
|
* safer guard: we only reclaim if the lock file is ALSO older than
|
|
150
151
|
* `staleAfterMs`, the same boundary `proper-lockfile` uses.
|
|
151
152
|
*
|
|
153
|
+
* F-3a7c4d67: `reason` is threaded all the way out to `withFileLock`'s
|
|
154
|
+
* ELOCKTIMEOUT error message (the `lastErrCtx` variable there was declared
|
|
155
|
+
* and read but never assigned anywhere — confirmed by grep before this fix:
|
|
156
|
+
* exactly two occurrences of the identifier in the whole file, declaration +
|
|
157
|
+
* the dead read). Every real lock-timeout previously rendered the bare
|
|
158
|
+
* "timed out after Nms waiting for X" with the promised "(last error: ...)"
|
|
159
|
+
* clause permanently absent.
|
|
160
|
+
*
|
|
152
161
|
* @param {string} lockPath
|
|
153
162
|
* @param {{ staleAfterMs?: number }} opts
|
|
154
|
-
* @returns {boolean}
|
|
163
|
+
* @returns {{ acquired: boolean, reason?: string }} `reason` is present only
|
|
164
|
+
* when `acquired` is false.
|
|
155
165
|
*/
|
|
156
166
|
function tryAcquire(lockPath, opts = {}) {
|
|
157
167
|
const { staleAfterMs = DEFAULT_STALE_AFTER_MS } = opts;
|
|
158
168
|
|
|
159
|
-
if (atomicCreateLock(lockPath)) return true;
|
|
169
|
+
if (atomicCreateLock(lockPath)) return { acquired: true };
|
|
160
170
|
|
|
161
171
|
// Lock exists — test for staleness.
|
|
162
172
|
let pidRaw = null;
|
|
@@ -175,7 +185,9 @@ function tryAcquire(lockPath, opts = {}) {
|
|
|
175
185
|
// PID-known case: trust process.kill to decide alive-vs-dead. This is the
|
|
176
186
|
// common case and gives fast crash recovery (sub-100ms typical).
|
|
177
187
|
if (pidRaw && pidRaw !== '') {
|
|
178
|
-
if (isProcessAlive(pidRaw))
|
|
188
|
+
if (isProcessAlive(pidRaw)) {
|
|
189
|
+
return { acquired: false, reason: `held by live process pid=${pidRaw}` };
|
|
190
|
+
}
|
|
179
191
|
|
|
180
192
|
// PID-dead reclaim via "rename to graveyard" — atomic claim that exactly
|
|
181
193
|
// one reclaimer wins. Without this, a sequence like:
|
|
@@ -208,7 +220,10 @@ function tryAcquire(lockPath, opts = {}) {
|
|
|
208
220
|
}
|
|
209
221
|
|
|
210
222
|
// Treat as live; the holder owns the lock and will release it.
|
|
211
|
-
return
|
|
223
|
+
return {
|
|
224
|
+
acquired: false,
|
|
225
|
+
reason: `lock file present with no readable pid; age below staleAfterMs=${staleAfterMs}ms`,
|
|
226
|
+
};
|
|
212
227
|
}
|
|
213
228
|
|
|
214
229
|
/**
|
|
@@ -232,7 +247,10 @@ function release(lockPath) {
|
|
|
232
247
|
*
|
|
233
248
|
* @param {string} lockPath
|
|
234
249
|
* @param {string} expectedDeadPid - The PID we observed and confirmed dead.
|
|
235
|
-
* @returns {boolean}
|
|
250
|
+
* @returns {{ acquired: boolean, reason?: string }} F-3a7c4d67: mirrors
|
|
251
|
+
* tryAcquire's return shape so the caller can thread a real cause into
|
|
252
|
+
* withFileLock's ELOCKTIMEOUT message instead of a permanently-null
|
|
253
|
+
* lastErrCtx.
|
|
236
254
|
*/
|
|
237
255
|
function reclaimViaGraveyard(lockPath, expectedDeadPid) {
|
|
238
256
|
const graveyardPath = `${lockPath}.gy.${process.pid}.${randomBytes(4).toString('hex')}`;
|
|
@@ -240,7 +258,7 @@ function reclaimViaGraveyard(lockPath, expectedDeadPid) {
|
|
|
240
258
|
renameSync(lockPath, graveyardPath);
|
|
241
259
|
} catch (err) {
|
|
242
260
|
// Another reclaimer beat us — let the retry loop sort it out.
|
|
243
|
-
return false;
|
|
261
|
+
return { acquired: false, reason: `stale-reclaim raced: another process already renamed ${lockPath} first` };
|
|
244
262
|
}
|
|
245
263
|
|
|
246
264
|
// We won the rename. Verify content.
|
|
@@ -262,7 +280,7 @@ function reclaimViaGraveyard(lockPath, expectedDeadPid) {
|
|
|
262
280
|
} catch {
|
|
263
281
|
try { unlinkSync(graveyardPath); } catch { /* drop */ }
|
|
264
282
|
}
|
|
265
|
-
return false;
|
|
283
|
+
return { acquired: false, reason: `stale-reclaim raced: lock was freshly re-acquired by pid=${actualPid} during reclaim` };
|
|
266
284
|
}
|
|
267
285
|
|
|
268
286
|
if (process.env.FILE_LOCK_DEBUG) {
|
|
@@ -270,7 +288,9 @@ function reclaimViaGraveyard(lockPath, expectedDeadPid) {
|
|
|
270
288
|
}
|
|
271
289
|
|
|
272
290
|
try { unlinkSync(graveyardPath); } catch { /* drop */ }
|
|
273
|
-
return atomicCreateLock(lockPath)
|
|
291
|
+
return atomicCreateLock(lockPath)
|
|
292
|
+
? { acquired: true }
|
|
293
|
+
: { acquired: false, reason: 'stale-reclaim raced: a fresh lock was created immediately after graveyard cleanup' };
|
|
274
294
|
}
|
|
275
295
|
|
|
276
296
|
/**
|
|
@@ -281,9 +301,23 @@ function reclaimViaGraveyard(lockPath, expectedDeadPid) {
|
|
|
281
301
|
* cascade through six callers and break the back-compat contract on
|
|
282
302
|
* `appendEvent` that review-engine relies on.
|
|
283
303
|
*
|
|
304
|
+
* F-3a7c4d67: guards its documented sibling `packages/ingest/lib/sleep-
|
|
305
|
+
* sync.js` already carries ("we don't trust caller arithmetic to never
|
|
306
|
+
* produce negative deltas if a system clock skews") were missing HERE.
|
|
307
|
+
* Independently re-confirmed empirically (isolated Node one-liner, no
|
|
308
|
+
* repo writes): `Atomics.wait(view, 0, 0, NaN)` never returns — it hangs
|
|
309
|
+
* the calling thread indefinitely, with no bound, no error, no log line,
|
|
310
|
+
* the worst possible outcome for a bounded-retry primitive. A negative
|
|
311
|
+
* value, by contrast, correctly clamps to an instant 'timed-out'. Reachable
|
|
312
|
+
* via `withFileLock(path, fn, { retryIntervalMs: NaN })` — e.g. a config
|
|
313
|
+
* value computed as `x / y` with `y === 0`, or `Number(process.env.X)` on
|
|
314
|
+
* an unset var — because the destructuring default on `retryIntervalMs`
|
|
315
|
+
* below only applies when the option is `undefined`, never when it is NaN.
|
|
316
|
+
*
|
|
284
317
|
* @param {number} ms
|
|
285
318
|
*/
|
|
286
319
|
function sleepSync(ms) {
|
|
320
|
+
if (!Number.isFinite(ms) || ms <= 0) return;
|
|
287
321
|
const sab = new SharedArrayBuffer(4);
|
|
288
322
|
const view = new Int32Array(sab);
|
|
289
323
|
Atomics.wait(view, 0, 0, ms);
|
|
@@ -326,10 +360,15 @@ export function withFileLock(targetPath, fn, options = {}) {
|
|
|
326
360
|
let lastErrCtx = null;
|
|
327
361
|
|
|
328
362
|
while (Date.now() < deadline) {
|
|
329
|
-
|
|
363
|
+
// F-3a7c4d67: tryAcquire now returns { acquired, reason } instead of a
|
|
364
|
+
// bare boolean specifically so lastErrCtx below stops being permanently
|
|
365
|
+
// null — see tryAcquire's own doc block for the full defect history.
|
|
366
|
+
const attempt = tryAcquire(lockPath, { staleAfterMs });
|
|
367
|
+
if (attempt.acquired) {
|
|
330
368
|
acquired = true;
|
|
331
369
|
break;
|
|
332
370
|
}
|
|
371
|
+
if (attempt.reason) lastErrCtx = attempt.reason;
|
|
333
372
|
sleepSync(retryIntervalMs);
|
|
334
373
|
}
|
|
335
374
|
|
package/lib/rename-with-retry.js
CHANGED
|
@@ -35,21 +35,46 @@ import { renameSync } from 'node:fs';
|
|
|
35
35
|
/**
|
|
36
36
|
* Sleep synchronously for `ms` milliseconds via `Atomics.wait` on a tiny
|
|
37
37
|
* SharedArrayBuffer. Yields the thread (does not spin). Mirrors the
|
|
38
|
-
* `sleepSync` in `file-lock.js
|
|
38
|
+
* `sleepSync` in `file-lock.js` — the contract is identical; the
|
|
39
39
|
* helper is duplicated here because rename-with-retry must not import the
|
|
40
40
|
* larger file-lock module (cyclic-import risk: atomic-write imports
|
|
41
41
|
* rename-with-retry, and file-lock imports atomic-write transitively
|
|
42
42
|
* through the lock-event write path).
|
|
43
43
|
*
|
|
44
|
+
* F-3a7c4d67 (sibling sweep): this file's own `ms <= 0` guard does NOT
|
|
45
|
+
* catch `ms = NaN` — every comparison with NaN is `false` in JS, so
|
|
46
|
+
* `NaN <= 0` is `false` and execution falls through to
|
|
47
|
+
* `Atomics.wait(view, 0, 0, NaN)`, independently confirmed (isolated
|
|
48
|
+
* one-liner, no repo writes) to hang the calling thread indefinitely, no
|
|
49
|
+
* bound, no error, no log line — the same defect class F-3a7c4d67 named in
|
|
50
|
+
* file-lock.js's sibling `sleepSync`, found here via that finding's own
|
|
51
|
+
* "sweep for every sibling" discipline, not named in the finding's own
|
|
52
|
+
* text. Reachable via `renameWithRetry(tmp, dest, { baseMs: NaN })` or
|
|
53
|
+
* `{ maxMs: NaN }` — `Math.min(NaN * 2**i, maxMs)` and
|
|
54
|
+
* `Math.min(baseMs * 2**i, NaN)` both evaluate to NaN, and the only current
|
|
55
|
+
* production caller (`atomic-write.js`) calls with zero options today, so
|
|
56
|
+
* this is latent-but-real against the function's own public option surface,
|
|
57
|
+
* not a live bug. Widened to the same `Number.isFinite` check
|
|
58
|
+
* `packages/ingest/lib/sleep-sync.js` already carries.
|
|
59
|
+
*
|
|
44
60
|
* @param {number} ms
|
|
45
61
|
*/
|
|
46
62
|
function sleepSync(ms) {
|
|
47
|
-
if (ms <= 0) return;
|
|
63
|
+
if (!Number.isFinite(ms) || ms <= 0) return;
|
|
48
64
|
const sab = new SharedArrayBuffer(4);
|
|
49
65
|
const view = new Int32Array(sab);
|
|
50
66
|
Atomics.wait(view, 0, 0, ms);
|
|
51
67
|
}
|
|
52
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Test-only: exercise the private `sleepSync` guard directly, without
|
|
71
|
+
* forcing a real EPERM/EBUSY filesystem race through `renameWithRetry`.
|
|
72
|
+
* Mirrors `file-lock.js`'s own `isLocked` test-only export precedent.
|
|
73
|
+
*/
|
|
74
|
+
export function __testSleepSync(ms) {
|
|
75
|
+
return sleepSync(ms);
|
|
76
|
+
}
|
|
77
|
+
|
|
53
78
|
export function renameWithRetry(tmp, dest, { retries = 10, baseMs = 15, maxMs = 200 } = {}) {
|
|
54
79
|
for (let i = 0; i <= retries; i++) {
|
|
55
80
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dogfood-lab/findings",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Finding contract spine for testing-os. Validates, reads, lists, and queries evidence-bound findings — the fourth contract alongside record, scenario, and policy.",
|
|
6
6
|
"main": "index.js",
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"dependencies": {
|
|
42
42
|
"@dogfood-lab/ingest": "^1.2.0",
|
|
43
43
|
"@dogfood-lab/schemas": "^1.2.0",
|
|
44
|
+
"@dogfood-lab/verify": "^1.2.0",
|
|
44
45
|
"js-yaml": "^4.1.0"
|
|
45
46
|
},
|
|
46
47
|
"engines": {
|
package/review/review-engine.js
CHANGED
|
@@ -21,11 +21,14 @@ import { atomicWriteFileSync } from '../lib/atomic-write.js';
|
|
|
21
21
|
* contract: free-text prose and the four classification enums. Identity,
|
|
22
22
|
* lineage, and evidence fields (finding_id, schema_version, source_record_ids,
|
|
23
23
|
* evidence, status, …) are excluded — they are set by the pipeline, not edited
|
|
24
|
-
* in place.
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
24
|
+
* in place. This constant IS the enforcement boundary: performAction's edit
|
|
25
|
+
* loop refuses any `--set` field not listed here (fvr-002). The write-side
|
|
26
|
+
* schema gate (dogfood-finding.schema.json's additionalProperties:false + enum
|
|
27
|
+
* checks) is the SECOND line — it validates the VALUES of allowed fields — but
|
|
28
|
+
* it cannot be the identity gate, because a valid finding_id swapped for
|
|
29
|
+
* another valid finding_id is schema-valid yet breaks the id↔path invariant.
|
|
30
|
+
* The same list drives the CLI help so the documented set and the enforced set
|
|
31
|
+
* share one source.
|
|
29
32
|
*/
|
|
30
33
|
export const EDITABLE_FIELDS = Object.freeze([
|
|
31
34
|
'title',
|
|
@@ -111,6 +114,20 @@ export function performAction(rootDir, params) {
|
|
|
111
114
|
if (field === '__proto__' || field === 'constructor' || field === 'prototype') {
|
|
112
115
|
return { success: false, error: `Field "${field}" is not editable (reserved/unsafe key)` };
|
|
113
116
|
}
|
|
117
|
+
// fvr-002 — allowlist enforcement, not just help text. `finding_id` (and
|
|
118
|
+
// every other identity/lineage/provenance field) is a VALID schema field,
|
|
119
|
+
// so mutating it to another well-formed value sails through the write-side
|
|
120
|
+
// schema gate while re-homing the finding's identity off its file path
|
|
121
|
+
// (findById matches the id INSIDE the file; the file still lands at the
|
|
122
|
+
// OLD path). The schema gate can only catch unknown/enum-invalid values,
|
|
123
|
+
// never a valid identity swapped for another valid one — so the allowlist
|
|
124
|
+
// is the enforcement boundary here, refusing anything `edit` is not for.
|
|
125
|
+
if (!EDITABLE_FIELDS.includes(field)) {
|
|
126
|
+
return {
|
|
127
|
+
success: false,
|
|
128
|
+
error: `Field "${field}" is not editable. Editable fields: ${EDITABLE_FIELDS.join(', ')}`
|
|
129
|
+
};
|
|
130
|
+
}
|
|
114
131
|
const oldValue = finding[field];
|
|
115
132
|
if (oldValue !== newValue) {
|
|
116
133
|
fieldChanges[field] = { from: oldValue, to: newValue };
|
package/review/transitions.js
CHANGED
|
@@ -13,12 +13,121 @@
|
|
|
13
13
|
* any status -> candidate (except initial creation)
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
/**
|
|
17
|
+
* F-271b4661 (wave 39, feature-pass integration finding) — cross-reference:
|
|
18
|
+
* this package's reopen/close guard vs. the swarm control-plane's C1/C2 verbs.
|
|
19
|
+
*
|
|
20
|
+
* docs/trajectory-and-closure.dispatch.md's Feature 2 (Closure Verbs) designs
|
|
21
|
+
* a FRESH guarded-reopen-with-mandatory-reason mechanism for the swarm's own
|
|
22
|
+
* per-run findings table: `swarm reopen` (C1) moves fixed|deferred|rejected
|
|
23
|
+
* -> recurring, requiring a non-empty reason AND evidence; `swarm close` (C2)
|
|
24
|
+
* closes a finding as fixed|rejected|deferred with a required `verified_how`.
|
|
25
|
+
* That is the SAME shape of guard this file already enforces, one package
|
|
26
|
+
* over, for a DIFFERENT 'finding':
|
|
27
|
+
*
|
|
28
|
+
* - REQUIRES_CLOSED (below) ~ C1's precondition that reopen is only
|
|
29
|
+
* legal from a closed status (accepted/rejected here; fixed/deferred/
|
|
30
|
+
* rejected on the control-plane side).
|
|
31
|
+
* - REASON_REQUIRED (below) ~ C2's mandatory `--reason` on `swarm
|
|
32
|
+
* close`. NOT C1: `swarm close`'s closing dispositions are the local
|
|
33
|
+
* analogue of this file's reject/invalidate/merge/supersede —
|
|
34
|
+
* REASON_REQUIRED's actual members — but `swarm reopen` (C1) mandates
|
|
35
|
+
* `--reason` AND `--evidence` while this file's own `reopen` action
|
|
36
|
+
* carries NEITHER. That asymmetry is real, not a documentation gap; see
|
|
37
|
+
* "NOT SHARED" below rather than assuming symmetry from this line alone.
|
|
38
|
+
* - ACTION_TARGET_STATUS.reopen ~ C1's `recurring` target status — both
|
|
39
|
+
* land a reopened item back in an amendable, non-terminal state
|
|
40
|
+
* (`reviewed` here, `recurring` there), never in the original pre-review
|
|
41
|
+
* state.
|
|
42
|
+
* - lineage.superseded_by (dogfood-finding.schema.json) ~ T5's 'versions
|
|
43
|
+
* supersede, nothing rewrites' — the same non-destructive-history
|
|
44
|
+
* principle, expressed as a schema field here and as roadmap sequence
|
|
45
|
+
* numbers + immutable finding_events rows on the control-plane side.
|
|
46
|
+
*
|
|
47
|
+
* SHARED DISCIPLINE (the same principle, independently earned in each place):
|
|
48
|
+
* - a closed/terminal state is a PRECONDITION for reopening, never a target
|
|
49
|
+
* (REQUIRES_CLOSED here; C1's own source-status check on the
|
|
50
|
+
* control-plane side);
|
|
51
|
+
* - history is additive — nothing here mutates a prior review-event-log
|
|
52
|
+
* entry, nothing on the control-plane side edits a prior finding_events
|
|
53
|
+
* row or a prior roadmap sequence; every reopen/close is itself a new,
|
|
54
|
+
* evidence-bearing record layered on top of what came before.
|
|
55
|
+
*
|
|
56
|
+
* NOT SHARED, DISCLOSED (F-a50d8106 — wave-41 rider, canonicalized from the
|
|
57
|
+
* collect-swallowed local label B40-002 at the wave-43 merge; this cross-reference
|
|
58
|
+
* comment previously listed the next line as shared discipline, which
|
|
59
|
+
* overclaimed): the control-plane's C1 `swarm reopen` requires a non-empty
|
|
60
|
+
* `--reason` (and `--evidence`) as a hard CLI-layer precondition — cli.js's
|
|
61
|
+
* cmdReopen refuses before any DB write (packages/dogfood-swarm/cli.js).
|
|
62
|
+
* THIS file's own `reopen` action carries no such requirement:
|
|
63
|
+
* REASON_REQUIRED (above) is {reject, invalidate, merge, supersede} —
|
|
64
|
+
* 'reopen' is deliberately absent, so performAction (review-engine.js) and
|
|
65
|
+
* reviewArtifact (review-artifacts.js, which reuses the same REASON_REQUIRED
|
|
66
|
+
* set) both accept a reopen with no reason at all. review.test.js's own
|
|
67
|
+
* 'Review actions: reopen' suite is live evidence of the gap: its
|
|
68
|
+
* 'requires reason' case calls performAction with action:'reopen' and NO
|
|
69
|
+
* reason field, and asserts success — the test's name promised the opposite
|
|
70
|
+
* of what its body proves, which is exactly the kind of drift this note
|
|
71
|
+
* exists to stop happening again at the comment level. Silently-reopenable
|
|
72
|
+
* was never a deliberate design choice on this side, and this note does not
|
|
73
|
+
* argue it should stay that way — only that a cross-reference asserting
|
|
74
|
+
* false symmetry is worse than one naming the gap outright, per this repo's
|
|
75
|
+
* own honesty-is-a-feature ethos (swarms/CLAUDE.md). Adding 'reopen' to
|
|
76
|
+
* REASON_REQUIRED is a behavior change out of scope for a comment-only fix
|
|
77
|
+
* (F-271b4661's own precedent) and belongs to a follow-up finding.
|
|
78
|
+
*
|
|
79
|
+
* DELIBERATELY DISTINCT (do not converge these — they answer different
|
|
80
|
+
* questions for two different concepts that both happen to be named
|
|
81
|
+
* 'finding'):
|
|
82
|
+
* - Granularity. This file's 'finding' is a distilled, reviewed,
|
|
83
|
+
* cross-repo lesson (dogfood-finding.schema.json) — the synthesis
|
|
84
|
+
* layer's output. The control-plane's 'finding' is a single dogfood-swarm
|
|
85
|
+
* wave's raw defect row, scoped to one run. Merging the two vocabularies
|
|
86
|
+
* would blur a reusable lesson with a per-run defect ticket.
|
|
87
|
+
* - Status vocabulary. candidate/reviewed/accepted/rejected (TRANSITIONS,
|
|
88
|
+
* below) vs. open/deferred/approved/fixed/rejected/recurring
|
|
89
|
+
* (packages/dogfood-swarm/lib/finding-status.js). No 1:1 mapping is
|
|
90
|
+
* attempted — reopen here targets 'reviewed' (an in-review state), C1
|
|
91
|
+
* targets 'recurring' (an explicitly-reopened state distinct from a
|
|
92
|
+
* fresh 'open' finding), because the two systems track different things
|
|
93
|
+
* at different points in their lifecycle.
|
|
94
|
+
* - Evidence shape. This file's REASON_REQUIRED actions attach a free-text
|
|
95
|
+
* `decision_reason` (event-log.js) plus, for reject, a structured
|
|
96
|
+
* `review.reject_reason` enum. C2 additionally requires `verified_how`
|
|
97
|
+
* (independent|self_attested|operator_evidence) — a THIRD 'how do we
|
|
98
|
+
* know' axis alongside this schema's own evidence[].evidence_kind (see
|
|
99
|
+
* that property's own cross-reference note, F-936c83f4) and
|
|
100
|
+
* packages/verify's --provenance=stub|github (see
|
|
101
|
+
* validators/provenance.js's file header). Not the same question:
|
|
102
|
+
* verified_how asks how a CLOSURE was verified; evidence_kind asks what
|
|
103
|
+
* backs a distilled lesson; --provenance asks whether a submitted CI run
|
|
104
|
+
* really happened.
|
|
105
|
+
*
|
|
106
|
+
* NO BEHAVIOR CHANGE. This is a comment-only fix (F-271b4661's recommendation
|
|
107
|
+
* option (a)) — it exists so a reader of either mechanism can find the other
|
|
108
|
+
* without re-deriving the comparison, and so the two vocabularies stay
|
|
109
|
+
* deliberately-distinct BY DECISION rather than accidentally-distinct because
|
|
110
|
+
* nobody had looked. C1/C2 are implemented in the swarm control plane
|
|
111
|
+
* (packages/dogfood-swarm — core/verbs domain, out of this package's scope);
|
|
112
|
+
* this file's own TRANSITIONS/REQUIRES_CLOSED/REASON_REQUIRED machinery is
|
|
113
|
+
* unchanged by this note.
|
|
114
|
+
*/
|
|
115
|
+
|
|
116
|
+
// F-937733ee: family sibling of F-2965699b/F-7ce07baa/verdict.js's
|
|
117
|
+
// VERDICT_RANK. Object.create(null) removes the prototype chain, so
|
|
118
|
+
// `TRANSITIONS['constructor']` is `undefined` rather than
|
|
119
|
+
// `Object.prototype.constructor` (a truthy function) — the `if (!allowed)`
|
|
120
|
+
// / `if (!TRANSITIONS[from])` unknown-status guards below fire correctly for
|
|
121
|
+
// every Object.prototype key instead of going on to call `.has()` on a
|
|
122
|
+
// built-in method and throwing a raw TypeError. `from` is currently always a
|
|
123
|
+
// schema-enum-constrained finding status, so this is defense-in-depth for a
|
|
124
|
+
// caller that skips that gate, not a live vector.
|
|
125
|
+
const TRANSITIONS = Object.assign(Object.create(null), {
|
|
17
126
|
candidate: new Set(['reviewed', 'accepted', 'rejected']),
|
|
18
127
|
reviewed: new Set(['accepted', 'rejected']),
|
|
19
128
|
accepted: new Set(['reviewed', 'rejected']),
|
|
20
129
|
rejected: new Set(['reviewed'])
|
|
21
|
-
};
|
|
130
|
+
});
|
|
22
131
|
|
|
23
132
|
/**
|
|
24
133
|
* Check if a status transition is lawful.
|
|
@@ -95,8 +95,24 @@ export function applyRecommendation(rootDir, params) {
|
|
|
95
95
|
// org/repo carrying `..` or a separator would escape the policies tree on
|
|
96
96
|
// BOTH the dry-run (path leaked in preview) and write (file touched) paths,
|
|
97
97
|
// so reject here before either branch resolves a path.
|
|
98
|
+
//
|
|
99
|
+
// F-853dbce9: the two-segment repo contract (F-54e5fde7 / V2-CROSS-BO-003)
|
|
100
|
+
// was NOT enforced here — the bare 2-way destructure silently DROPS every
|
|
101
|
+
// segment past the second, so `--policy group/subgroup/project` yielded
|
|
102
|
+
// pOrg='group', pRepo='subgroup' and resolved a DIFFERENT repo's policy
|
|
103
|
+
// file than the operator named, with no error on either the dry-run
|
|
104
|
+
// preview or the --write path. Matches persist.js:51's `segments.length
|
|
105
|
+
// !== 2` guard verbatim so the family reads identically.
|
|
98
106
|
if (params.policyRepo) {
|
|
99
|
-
const
|
|
107
|
+
const segments = String(params.policyRepo).split('/');
|
|
108
|
+
if (segments.length !== 2) {
|
|
109
|
+
return structuredError(
|
|
110
|
+
'RECOMMENDATION_UNSAFE_POLICY',
|
|
111
|
+
`policy repo "${params.policyRepo}" is not a two-segment org/repo slug`,
|
|
112
|
+
'Nested GitLab subgroups are unsupported — pass --policy <org>/<repo>.'
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
const [pOrg, pRepo] = segments;
|
|
100
116
|
if (!pOrg || !pRepo || isUnsafeSegment(pOrg) || isUnsafeSegment(pRepo)) {
|
|
101
117
|
return structuredError(
|
|
102
118
|
'RECOMMENDATION_UNSAFE_POLICY',
|
|
@@ -196,7 +212,18 @@ export function applyRecommendation(rootDir, params) {
|
|
|
196
212
|
}
|
|
197
213
|
|
|
198
214
|
// Apply the structured intent: add target to surfaces.<surface>.required_scenarios.
|
|
199
|
-
|
|
215
|
+
//
|
|
216
|
+
// F-5dfddcb5: policy.surfaces is a dynamic-key container (`[surface]`,
|
|
217
|
+
// below) built from a value this module does not itself constrain to a
|
|
218
|
+
// safe enum — Object.create(null) so a `surface` value of '__proto__'
|
|
219
|
+
// reassigns an own data property instead of the object's [[Prototype]].
|
|
220
|
+
// Mirrors the identical fix already shipped in this domain for the same
|
|
221
|
+
// assignment shape: packages/ingest/rebuild-indexes.js (F-89b7dcd5),
|
|
222
|
+
// packages/portfolio/lib/compute-trends.js (F-a853fcaa). Not reachable
|
|
223
|
+
// today — `surface` comes from `rec.applies_to.product_surfaces`, which is
|
|
224
|
+
// schema-enum-constrained — but this is the same defense-in-depth posture
|
|
225
|
+
// the sibling fixes already established for this pattern.
|
|
226
|
+
if (!policy.surfaces) policy.surfaces = Object.create(null);
|
|
200
227
|
if (!policy.surfaces[surface]) policy.surfaces[surface] = {};
|
|
201
228
|
if (!Array.isArray(policy.surfaces[surface].required_scenarios)) {
|
|
202
229
|
policy.surfaces[surface].required_scenarios = [];
|
|
@@ -209,7 +236,28 @@ export function applyRecommendation(rootDir, params) {
|
|
|
209
236
|
// logic field. Stored under a dedicated `applied_recommendations` map keyed by
|
|
210
237
|
// the scenario id so it is auditable but never interpreted by the policy
|
|
211
238
|
// engine. The free-text details live here as a human note, never as a rule.
|
|
212
|
-
|
|
239
|
+
//
|
|
240
|
+
// F-5dfddcb5: Object.create(null), not {} — action.target is FREE TEXT
|
|
241
|
+
// (≤100 chars, no enum constraint, per this file's own header docstring)
|
|
242
|
+
// used as the dynamic key immediately below. On a plain {}, the bracket
|
|
243
|
+
// assignment `container['__proto__'] = provenance` does not create an own
|
|
244
|
+
// '__proto__' data property — it invokes Object.prototype's __proto__
|
|
245
|
+
// SETTER, which reassigns the CONTAINER's own [[Prototype]] to the
|
|
246
|
+
// provenance object. The write silently vanishes (Object.keys() stays [])
|
|
247
|
+
// and the container starts inheriting provenance's own properties instead
|
|
248
|
+
// — confirmed with a standalone probe matching this exact single-assignment
|
|
249
|
+
// shape. (This is scoped to the one container object, not a write onto the
|
|
250
|
+
// shared, global Object.prototype — that stronger form needs a read-then-
|
|
251
|
+
// write through an unguarded lookup, which is a different pattern.)
|
|
252
|
+
// Object.create(null) removes the [[Prototype]] chain entirely, so
|
|
253
|
+
// '__proto__' is just an ordinary key. NOT reachable from untrusted input
|
|
254
|
+
// today — the only current producer (recommendation-derivation.js's
|
|
255
|
+
// selectTemplate()) emits hardcoded literal targets — but a future
|
|
256
|
+
// template letting action.target reflect free-form scenario-id-shaped text
|
|
257
|
+
// must not silently reopen this class. Mirrors the identical fix already
|
|
258
|
+
// shipped for this pattern: packages/ingest/rebuild-indexes.js
|
|
259
|
+
// (F-89b7dcd5), packages/portfolio/lib/compute-trends.js (F-a853fcaa).
|
|
260
|
+
if (!policy.applied_recommendations) policy.applied_recommendations = Object.create(null);
|
|
213
261
|
const provenance = {
|
|
214
262
|
recommendation_id: id,
|
|
215
263
|
action_type: action.type,
|
|
@@ -252,8 +300,23 @@ export function applyRecommendation(rootDir, params) {
|
|
|
252
300
|
};
|
|
253
301
|
}
|
|
254
302
|
|
|
255
|
-
/**
|
|
303
|
+
/**
|
|
304
|
+
* Resolve the on-disk path for a repo policy under rootDir.
|
|
305
|
+
*
|
|
306
|
+
* F-853dbce9: fails closed on the same two-segment invariant the guard above
|
|
307
|
+
* enforces, rather than trusting every caller to have checked first —
|
|
308
|
+
* mirrors persist.js's computeRecordPath, which owns this same invariant at
|
|
309
|
+
* the write layer instead of trusting its own callers. Both of this
|
|
310
|
+
* function's current callers (the dry-run preview and the --write path) are
|
|
311
|
+
* already gated by the guard above, so this throw is defense-in-depth, not
|
|
312
|
+
* the primary enforcement point — but a future call site that skips the
|
|
313
|
+
* guard must fail loud here, not silently resolve a different repo's file.
|
|
314
|
+
*/
|
|
256
315
|
function policyPathFor(rootDir, orgRepo) {
|
|
257
|
-
const
|
|
316
|
+
const segments = String(orgRepo).split('/');
|
|
317
|
+
if (segments.length !== 2 || !segments[0] || !segments[1]) {
|
|
318
|
+
throw new Error(`policy repo "${orgRepo}" is not a two-segment org/repo slug`);
|
|
319
|
+
}
|
|
320
|
+
const [org, repo] = segments;
|
|
258
321
|
return resolve(rootDir, 'policies', 'repos', org, `${repo}.yaml`);
|
|
259
322
|
}
|