T7.3 - CR-015/A1/D4: the hold clears when the constraints do
ROOT CAUSE, exactly (the done-when asks for it):
Hold state was stored, twice, and derived nowhere.
1) Client: submitHold() wrote prevStatus='Issue', destroying the status the
hold interrupted at the moment it was placed - there was never anything to
return to. Clearing the last constraint then fell into the "Mark it as
Issued now?" confirm, because STATUS_ORDER.indexOf('Issue') is -1 and -1
reads as "before Issued". Decline it and the package stayed on hold with
zero open constraints, forever - the exact state reproduced live in front
of the Micron team.
2) Server: server/app.py's STATUS_ORDER put "Issue" at index 4, so
_released('Issue') was true and every transition OUT of hold skipped
enforce_release_gates() as "already released". POST /api/wps/{id}/status
could walk a held package to Issued past its open constraint. The comment
claimed the ladder was "mirrored in the front end"; the front end's ladder
has no 'Issue' in it at all.
What changed:
- setConstraint() recalculates hold state on EVERY constraint change: clearing
the last open constraint on a held package releases it immediately - no
refresh, no dialog - back to the status recorded on the hold entry (`from`),
which now rides on data.holds and survives save/reload.
- Every hold and release is history: pkgHolds entries carry ts, by, from/to,
reason; the exported Hold Log gained a By column; the server writes
hold_logged / hold_released audit rows (with the reason from data.holds) on
both the upsert and the /status endpoint.
- _released() no longer counts the hold: 'Issue' is a branch, not a rung.
Leaving hold to a field state re-runs the gates; entering hold never did and
still does not. The critical-reopen email keeps its old reach ("has been in
the field" includes on-hold).
- A1 preserved by name and by test: confirmEarlyRelease() still the one place
a gate override is written (comment-stripped grep asserts exactly one
pkgGateOverride assignment), still reason-first, still logged server-side.
D4 - what Urgent does (amended Aug 18): surface the audited path, add no new
one. confirmEarlyRelease() now also covers open constraints, but only for an
Urgent package, and the override must NAME every constraint it crosses - the
server refuses coverage by an old reason. The release banner gives an Urgent
package the override as its primary action (a real <button>); Normal and High
see nothing new and keep the same hard refusal, asserted per priority.
Banner button styled from tokens only; the banner now wraps at narrow widths.
Product question raised, not decided (per CLAUDE.md "asking versus assuming"):
Issue (hold) remains selectable from Draft and Scheduled, as it was before.
The done-when names no state list, so nothing was restricted. If a pre-release
hold is meaningless, closing it off is a one-line follow-up - needs Nick.
Verification (each probe run alone): NEW tests/hold_check.py 50/50, including
the clear-last-constraint regression specifically, the D4 priority matrix
against the server (six 409/200 cases), hold_logged/hold_released audit rows,
and an AST sweep proving every wp.status assignment in server/app.py sits in
a function that runs enforce_release_gates. Regressions: frame_check 39/39,
aggregates_check 16/16.
Items: CR-015, A1, D4 (X2 correction already recorded Aug 18)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -277,6 +277,7 @@ Wave 7 adds these:
|
||||
```bash
|
||||
python tests/frame_check.py # B7/T7.1/D1 - is the iframe actually gone? 39 checks
|
||||
python tests/form_structure_check.py # F6/D3 - rail, disclosure, one open section 51 checks
|
||||
python tests/hold_check.py # CR-015/A1/D4 - the hold clears, gates hold 50 checks
|
||||
```
|
||||
|
||||
**Three probes were re-pointed at `T7.1`.** `sections_check.py` 5b drove the live
|
||||
|
||||
@@ -949,6 +949,13 @@ function setConstraint(i,val){
|
||||
// issue it and scroll up to the status control so the change is visible.
|
||||
if(before==='open' && val!=='open' && readiness().open===0){
|
||||
const st=getRadio('status');
|
||||
// CR-015: hold state is recalculated on EVERY constraint change, never read
|
||||
// once and left to go stale. Clearing the last open constraint releases the
|
||||
// hold right here - no refresh, no dialog - and returns the package to the
|
||||
// status it held when the hold was placed. The old code fell through to the
|
||||
// "Mark it as Issued now?" offer because STATUS_ORDER.indexOf('Issue') is -1,
|
||||
// and declining it left the package on hold with nothing open.
|
||||
if(st==='Issue'){ releaseHold(pkgConstraints[i].name); return; }
|
||||
// Don't offer to issue while a predecessor is still open — that would walk the
|
||||
// user straight into the override prompt they didn't ask for.
|
||||
if(readiness().blocking.length){
|
||||
@@ -992,6 +999,12 @@ function updateReleaseBanner(){
|
||||
cls='rb-notready';
|
||||
const extra = r.blocking.length ? ` · also waiting on ${r.blocking.length} predecessor${r.blocking.length===1?'':'s'}` : '';
|
||||
txt=`⚠ Not release-ready — ${r.open} of ${r.total} constraint${r.open===1?'':'s'} still open${extra}.`;
|
||||
// D4: an Urgent package gets the audited override as the banner's primary
|
||||
// action - not a hidden menu item, and not on Normal or High, whose gate is
|
||||
// exactly as prominent as it was before this change.
|
||||
if(STATUS_ORDER.indexOf(st)<ISSUED_IDX && wpPriorityOf({priority: gv('wp_priority')})==='Urgent'){
|
||||
txt+=` <button type="button" class="rb-act" onclick="urgentOverrideRelease()">Release now — audited override</button>`;
|
||||
}
|
||||
}
|
||||
b.innerHTML=`<div class="rb-inner ${cls}">${txt}</div>`;
|
||||
updateStickyStatus();
|
||||
@@ -999,9 +1012,17 @@ function updateReleaseBanner(){
|
||||
// Releasing with an unclosed predecessor is allowed but must be explained. The
|
||||
// reason rides on the package (data.gateOverride) and the server writes it to the
|
||||
// audit log. Returns false if the user backed out.
|
||||
function confirmEarlyRelease(blocking){
|
||||
const list=blocking.map(p=>'• '+(p.number||p.id)+' — '+p.status).join('\n');
|
||||
const reason=prompt('These predecessor packages are not Closed yet:\n\n'+list+
|
||||
function confirmEarlyRelease(blocking, openConstraints){
|
||||
// D4 extended this to open constraints on an URGENT package - the same audited
|
||||
// path, not a new one. A silent bypass would destroy the delay-documentation
|
||||
// use case that justifies the constraint workflow, so the reason is mandatory
|
||||
// and the override records exactly which gates it crossed.
|
||||
const open=(openConstraints||[]).slice();
|
||||
const parts=[];
|
||||
if(open.length) parts.push('These constraints are still OPEN:\n\n'+open.map(n=>'• '+n).join('\n'));
|
||||
if(blocking.length) parts.push('These predecessor packages are not Closed yet:\n\n'+
|
||||
blocking.map(p=>'• '+(p.number||p.id)+' — '+p.status).join('\n'));
|
||||
const reason=prompt(parts.join('\n\n')+
|
||||
'\n\nYou can still release this package, but the reason is recorded on it and in the audit log.\n\n'+
|
||||
'Why is it being released now? (Cancel to stop.)');
|
||||
if(reason===null || !reason.trim()) return false;
|
||||
@@ -1011,18 +1032,79 @@ function confirmEarlyRelease(blocking){
|
||||
by: (window.WP_USER && (WP_USER.full_name||WP_USER.username)) || '',
|
||||
blocking: blocking.map(p=>p.number||p.id)
|
||||
};
|
||||
// The server only honours a constraint override that NAMES what it covers, so
|
||||
// a constraint opened after the override cannot ride through on an old reason.
|
||||
if(open.length) pkgGateOverride.constraints=open;
|
||||
track('predecessor_gate_overridden');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Which status an on-hold package returns to. The hold entry records it at the
|
||||
// moment the hold is placed (`from`); the newest hold entry wins. Falls back to
|
||||
// 'Issued' for a package held before this field existed - it must have been
|
||||
// released to be flagged, and 'Issued' is the most conservative released state.
|
||||
function holdReturnStatus(){
|
||||
for(let i=pkgHolds.length-1;i>=0;i--){
|
||||
const h=pkgHolds[i];
|
||||
if(!h || h.released) continue;
|
||||
if(h.from && STATUS_ORDER.indexOf(h.from)>=0) return h.from;
|
||||
}
|
||||
if(prevStatus && STATUS_ORDER.indexOf(prevStatus)>=0) return prevStatus;
|
||||
return 'Issued';
|
||||
}
|
||||
|
||||
// CR-015: leaving hold. Writes the release to the same history the hold went to -
|
||||
// timestamp, user, what cleared it - and returns to the recorded prior status.
|
||||
// Predecessors stay a refusable gate on the way back out (A1): if one reopened
|
||||
// while the package sat on hold, the audited override is offered, never skipped.
|
||||
function releaseHold(clearedName){
|
||||
const back=holdReturnStatus();
|
||||
const r=readiness();
|
||||
if(STATUS_ORDER.indexOf(back)>=ISSUED_IDX && r.blocking.length && !pkgGateOverride){
|
||||
if(!confirmEarlyRelease(r.blocking)){
|
||||
toast('Constraint cleared — still on hold: predecessor package(s) are not Closed.');
|
||||
updateReleaseBanner(); return;
|
||||
}
|
||||
}
|
||||
pkgHolds.push({ ts:new Date().toISOString(), released:true,
|
||||
constraint:clearedName||'',
|
||||
details:'Hold released — last open constraint cleared'+(clearedName?': '+clearedName:''),
|
||||
by:(window.WP_USER && (WP_USER.full_name||WP_USER.username)) || '',
|
||||
to:back });
|
||||
setRadio('status', back); prevStatus=back;
|
||||
updateReleaseBanner(); toast('Hold released — back to '+back+'.');
|
||||
track('hold_released',{to:back});
|
||||
const sg=document.getElementById('status-group');
|
||||
if(sg) sg.scrollIntoView({behavior:'smooth', block:'center'});
|
||||
}
|
||||
|
||||
// D4: the banner's primary action for an Urgent package blocked by constraints.
|
||||
// It runs the SAME audited path the status control runs - one override, one log.
|
||||
function urgentOverrideRelease(){
|
||||
const r=readiness();
|
||||
const open=pkgConstraints.filter(c=>c.status==='open').map(c=>c.name);
|
||||
if(!open.length && !r.blocking.length){ updateReleaseBanner(); return; }
|
||||
if(!confirmEarlyRelease(r.blocking, open)) return;
|
||||
setRadio('status','Issued'); prevStatus='Issued';
|
||||
updateReleaseBanner(); track('status_change',{status:'Issued', via:'urgent_override'});
|
||||
}
|
||||
|
||||
function onStatusChange(target){
|
||||
const idx=STATUS_ORDER.indexOf(target);
|
||||
const r=readiness();
|
||||
// Constraints are a hard gate: nothing releases with one open.
|
||||
// Constraints are a hard gate for Normal and High: nothing releases with one
|
||||
// open. For an URGENT package the gate is refusable through the audited
|
||||
// override (D4) - the same confirmEarlyRelease() path, never a silent bypass.
|
||||
if(idx>=ISSUED_IDX && r.open>0){
|
||||
const open=pkgConstraints.filter(c=>c.status==='open').map(c=>c.name);
|
||||
alert('Cannot move to "'+target+'" — these constraints are still open:\n\n• '+open.join('\n• ')+'\n\nClear or mark N/A first.');
|
||||
setRadio('status', prevStatus||'Scheduled'); updateReleaseBanner(); return;
|
||||
if(wpPriorityOf({priority: gv('wp_priority')})==='Urgent'){
|
||||
if(!confirmEarlyRelease(r.blocking, open)){
|
||||
setRadio('status', prevStatus||'Scheduled'); updateReleaseBanner(); return;
|
||||
}
|
||||
} else {
|
||||
alert('Cannot move to "'+target+'" — these constraints are still open:\n\n• '+open.join('\n• ')+'\n\nClear or mark N/A first.');
|
||||
setRadio('status', prevStatus||'Scheduled'); updateReleaseBanner(); return;
|
||||
}
|
||||
}
|
||||
// Predecessors are a gate you can refuse: planners genuinely need to release
|
||||
// ahead of upstream work closing out. Refusing it requires a reason, which is
|
||||
@@ -1057,7 +1139,11 @@ function submitHold(){
|
||||
const details=document.getElementById('hold-details').value.trim();
|
||||
const doclink=document.getElementById('hold-doclink').value.trim();
|
||||
if(!details){ alert('A comment defining the issue is required.'); return; }
|
||||
pkgHolds.push({ ts:new Date().toISOString(), constraint, details, doc:doclink, photo:holdPhotoData||'' });
|
||||
pkgHolds.push({ ts:new Date().toISOString(), constraint, details, doc:doclink, photo:holdPhotoData||'',
|
||||
by:(window.WP_USER && (WP_USER.full_name||WP_USER.username)) || '',
|
||||
// What the release returns to. prevStatus is captured before the status pill
|
||||
// switches; guard against re-holding while already on hold.
|
||||
from:(prevStatus && prevStatus!=='Issue') ? prevStatus : holdReturnStatus() });
|
||||
const c=pkgConstraints.find(x=>x.name===constraint); if(c){ c.status='open'; c.comment=details; }
|
||||
buildConstraints(); setRadio('status','Issue'); prevStatus='Issue'; holdContext=null;
|
||||
document.getElementById('hold-modal').classList.remove('open');
|
||||
@@ -1319,9 +1405,9 @@ function renderPackage(pkg){
|
||||
</tbody></table>`);
|
||||
|
||||
if(pkg.holds&&pkg.holds.length){
|
||||
let t=`<table><thead><tr><th style="width:150px">Logged</th><th style="width:200px">Constraint</th><th>Details</th><th style="width:120px">Support</th></tr></thead><tbody>`;
|
||||
let t=`<table><thead><tr><th style="width:150px">Logged</th><th style="width:130px">By</th><th style="width:180px">Constraint</th><th>Details</th><th style="width:120px">Support</th></tr></thead><tbody>`;
|
||||
pkg.holds.forEach(hd=>{ const when=hd.ts?wpFormatDateTime(hd.ts):''; const sup=[hd.doc?linkify(hd.doc):'', hd.photo?'<span style="color:var(--accent-green)">photo attached</span>':''].filter(Boolean).join('<br>')||ns();
|
||||
t+=`<tr><td>${esc(when)}</td><td>${cell(hd.constraint)}</td><td>${cell(hd.details)}</td><td>${sup}</td></tr>`; });
|
||||
t+=`<tr><td>${esc(when)}</td><td>${cell(hd.by)}</td><td>${cell(hd.constraint)}</td><td>${cell(hd.details)}</td><td>${sup}</td></tr>`; });
|
||||
// The hold LOG belongs to QA/QC — it is the record of the hold points that
|
||||
// section defines, so it goes with it rather than surviving on its own.
|
||||
add('qaqc', 'Hold Log', t+`</tbody></table>`);
|
||||
|
||||
@@ -564,10 +564,16 @@
|
||||
.sop-hint { color:var(--accent) !important; }
|
||||
.release-banner { max-width:none; margin:0; padding:0 28px 0 calc(var(--nav-w,288px) + 28px); }
|
||||
.release-banner .rb-inner { margin-top:14px; border-radius:var(--radius); padding:11px 16px; font-size:13px; font-weight:600;
|
||||
display:flex; align-items:center; gap:10px; }
|
||||
display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
|
||||
.rb-ready { background:var(--accent-green-dim); color:var(--accent-green); border:1px solid var(--wp-status-success-border-b); }
|
||||
.rb-notready { background:var(--accent-amber-dim); color:var(--accent-amber); border:1px solid var(--wp-status-warning-border-b); }
|
||||
.rb-hold { background:var(--red-dim); color:var(--red); border:1px solid var(--wp-status-error-border-b); }
|
||||
/* D4: the audited-override action on the release banner. A real button in the
|
||||
primary position for an Urgent package; it simply never renders otherwise. */
|
||||
.rb-act { margin-left:auto; border:none; border-radius:var(--radius); cursor:pointer;
|
||||
background:var(--primary); color:var(--cds-text-on-color); font-family:var(--sans);
|
||||
font-size:12px; font-weight:600; padding:7px 14px; min-height:32px; }
|
||||
.rb-act:hover { background:var(--cds-hover-primary); }
|
||||
.pill-hold.selected { background:var(--red) !important; border-color:var(--red) !important; }
|
||||
.pill-hold.selected .dot { background:var(--cds-text-on-color) !important; }
|
||||
|
||||
|
||||
@@ -1405,14 +1405,23 @@ def delete_sop(sop_id: str, user: models.User = Depends(auth.get_current_user),
|
||||
|
||||
|
||||
# ── Release gates (constraints + predecessors) ─────────────────────────────────
|
||||
# Status ladder, mirrored in the front end (wp-creation-app.js STATUS_ORDER).
|
||||
# Status ladder. The front end's STATUS_ORDER (wp-creation-app.js) has no "Issue"
|
||||
# in it at all - the hold is a BRANCH off the released states, not a rung. It sits
|
||||
# in this list so unknown statuses can still be told apart from known ones, but
|
||||
# _released() must never count it: counting it is what let every transition out of
|
||||
# hold skip the release gates as "already released", which made /status a side
|
||||
# door past an open constraint (CR-015 / T7.3).
|
||||
STATUS_ORDER = ["Draft", "Scheduled", "Issued", "In Progress", "Issue", "QC", "Closed"]
|
||||
ISSUED_IDX = STATUS_ORDER.index("Issued")
|
||||
DONE_STATUS = "Closed"
|
||||
HOLD_STATUS = "Issue"
|
||||
|
||||
|
||||
def _released(status: Optional[str]) -> bool:
|
||||
"""Has this package been released to the field (Issued or anything after)?"""
|
||||
"""Is this status a field state (Issued or beyond)? The hold is NOT one: a
|
||||
package leaves hold through the gates, and enters it without them."""
|
||||
if status == HOLD_STATUS:
|
||||
return False
|
||||
try:
|
||||
return STATUS_ORDER.index(status or "") >= ISSUED_IDX
|
||||
except ValueError:
|
||||
@@ -1438,6 +1447,23 @@ def gate_override(data: Optional[dict]) -> Optional[dict]:
|
||||
return None
|
||||
|
||||
|
||||
def _urgent_constraint_override(data: Optional[dict], open_names: list) -> bool:
|
||||
"""D4: only an URGENT package may release past an open constraint, and only
|
||||
through the audited override - the same gateOverride record the predecessor
|
||||
gate uses, never a separate path. The override must NAME every constraint it
|
||||
covers: a constraint opened after the reason was written cannot ride through
|
||||
on it. Normal and High are unchanged - a hard refusal."""
|
||||
if str((data or {}).get("priority") or "") != "Urgent":
|
||||
return False
|
||||
ov = gate_override(data)
|
||||
if not ov:
|
||||
return False
|
||||
covered = ov.get("constraints")
|
||||
if not isinstance(covered, list):
|
||||
return False
|
||||
return {str(n) for n in open_names} <= {str(c) for c in covered}
|
||||
|
||||
|
||||
def blocking_predecessors(db: Session, wp_id: Optional[str], data: Optional[dict]) -> list[dict]:
|
||||
"""Predecessors that are not Closed yet. A predecessor that no longer exists is
|
||||
NOT blocking — a deleted package must not freeze everything downstream."""
|
||||
@@ -1486,7 +1512,7 @@ def enforce_release_gates(db: Session, wp_id: Optional[str], data: Optional[dict
|
||||
return # not a release transition
|
||||
constraints = (data or {}).get("constraints") or []
|
||||
open_names = [c.get("name") for c in constraints if isinstance(c, dict) and c.get("status") == "open"]
|
||||
if open_names:
|
||||
if open_names and not _urgent_constraint_override(data, open_names):
|
||||
raise HTTPException(status_code=409, detail={
|
||||
"message": "Open constraints block release", "open": open_names,
|
||||
})
|
||||
@@ -1633,13 +1659,35 @@ def upsert_wp(body: WpIn, background_tasks: BackgroundTasks, user: models.User =
|
||||
ov = gate_override(body.data)
|
||||
if ov and _released(wp.status) and not _released(old_status):
|
||||
blockers = blocking_predecessors(db, wp.id, body.data)
|
||||
_ov_constraints = [str(c) for c in ov.get("constraints") or [] if c]
|
||||
log_event(db, user, "gate_overridden", "wp", wp.id, project_id=wp.project_id,
|
||||
summary=(wp.number or wp.subject or wp.id),
|
||||
detail={"reason": str(ov.get("reason"))[:300],
|
||||
"blocking": [b["number"] or b["id"] for b in blockers]})
|
||||
"blocking": [b["number"] or b["id"] for b in blockers],
|
||||
"constraints": _ov_constraints})
|
||||
# CR-015: holds and releases are history, not just state. Entering or leaving
|
||||
# the hold branch gets its own audit line with the actor and the reason the
|
||||
# browser recorded in data.holds - `status_changed` alone says from/to but
|
||||
# not why, and the WHY is what a notice of delay is built from.
|
||||
if not is_new and old_status != wp.status and "Issue" in (old_status, wp.status):
|
||||
_hlds = [h for h in ((wp.data or {}).get("holds") or []) if isinstance(h, dict)]
|
||||
if wp.status == "Issue":
|
||||
_h = next((h for h in reversed(_hlds) if not h.get("released")), None)
|
||||
log_event(db, user, "hold_logged", "wp", wp.id, project_id=wp.project_id,
|
||||
summary=(wp.number or wp.subject or wp.id),
|
||||
detail={"from": old_status,
|
||||
"constraint": str((_h or {}).get("constraint") or "")[:200],
|
||||
"reason": str((_h or {}).get("details") or "")[:300]})
|
||||
else:
|
||||
_h = next((h for h in reversed(_hlds) if h.get("released")), None)
|
||||
log_event(db, user, "hold_released", "wp", wp.id, project_id=wp.project_id,
|
||||
summary=(wp.number or wp.subject or wp.id),
|
||||
detail={"to": wp.status,
|
||||
"constraint": str((_h or {}).get("constraint") or "")[:200],
|
||||
"reason": str((_h or {}).get("details") or "")[:300]})
|
||||
# A critical constraint reopened after release: log it and tell the people who
|
||||
# need to know (owner, PM, CM, distribution).
|
||||
if not is_new and _released(old_status):
|
||||
if not is_new and (_released(old_status) or old_status == HOLD_STATUS):
|
||||
reopened = reopened_critical(old_data, wp.data)
|
||||
if reopened:
|
||||
log_event(db, user, "constraint_reopened", "wp", wp.id, project_id=wp.project_id,
|
||||
@@ -2386,6 +2434,22 @@ def set_wp_status(wp_id: str, body: StatusIn, user: models.User = Depends(auth.g
|
||||
if old_status != body.status:
|
||||
log_event(db, user, "status_changed", "wp", wp.id, project_id=wp.project_id,
|
||||
summary=(wp.number or wp.subject or wp.id), detail={"from": old_status, "to": body.status})
|
||||
if "Issue" in (old_status, body.status):
|
||||
_hlds = [h for h in ((wp.data or {}).get("holds") or []) if isinstance(h, dict)]
|
||||
if body.status == "Issue":
|
||||
_h = next((h for h in reversed(_hlds) if not h.get("released")), None)
|
||||
log_event(db, user, "hold_logged", "wp", wp.id, project_id=wp.project_id,
|
||||
summary=(wp.number or wp.subject or wp.id),
|
||||
detail={"from": old_status,
|
||||
"constraint": str((_h or {}).get("constraint") or "")[:200],
|
||||
"reason": str((_h or {}).get("details") or "")[:300]})
|
||||
else:
|
||||
_h = next((h for h in reversed(_hlds) if h.get("released")), None)
|
||||
log_event(db, user, "hold_released", "wp", wp.id, project_id=wp.project_id,
|
||||
summary=(wp.number or wp.subject or wp.id),
|
||||
detail={"to": body.status,
|
||||
"constraint": str((_h or {}).get("constraint") or "")[:200],
|
||||
"reason": str((_h or {}).get("details") or "")[:300]})
|
||||
db.commit()
|
||||
db.refresh(wp)
|
||||
return wp.to_dict()
|
||||
|
||||
480
tests/hold_check.py
Normal file
480
tests/hold_check.py
Normal file
@@ -0,0 +1,480 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Does clearing the last constraint clear the hold? — CR-015 / A1 / D4, T7.3.
|
||||
|
||||
`CR-015` is the highest-severity item in the plan and it was reproduced live in
|
||||
front of the Micron team: a package on hold with every constraint cleared, stuck.
|
||||
The root cause was hold state STORED rather than derived — `submitHold()` wrote
|
||||
`prevStatus='Issue'`, destroying the status the hold interrupted, and the clear
|
||||
path fell into the "Mark it as Issued now?" offer because `'Issue'` is not in
|
||||
`STATUS_ORDER` (`indexOf` gives -1, which reads as "before Issued"). Declining
|
||||
that offer left the package on hold with nothing open, forever.
|
||||
|
||||
1. the clear-last-constraint path, specifically (the regression this file exists for)
|
||||
2. D4: an Urgent package gets the audited override as the primary action; Normal and High are unchanged
|
||||
3. Issue (Hold) branches from every released state
|
||||
4. the server refuses what the browser refuses, and writes the history
|
||||
|
||||
The dialog stubs record every native dialog: the release path must fire NONE.
|
||||
The prompt stub is how the audited override is driven — its return value is the
|
||||
reason, and None is the user backing out.
|
||||
|
||||
Self-contained: throwaway SQLite, its own uvicorn, headless Edge or Chrome.
|
||||
Exit 0 all passed, 1 a failure, 2 could not run.
|
||||
"""
|
||||
import ast
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import cdp # noqa: E402
|
||||
from browser_check import seed, start_server, chk, _PASS, _FAIL, _c # noqa: E402
|
||||
from sections_check import set_sop # noqa: E402
|
||||
from stepper_check import dismiss_dialogs # noqa: E402
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def ascii_(v, n=300):
|
||||
return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n]
|
||||
|
||||
|
||||
def settle(seconds=0.8):
|
||||
time.sleep(seconds)
|
||||
|
||||
|
||||
def wait_creator(page, tries=40):
|
||||
for _ in range(tries):
|
||||
if page.eval("!!window.wpCreatorReady"):
|
||||
return True
|
||||
time.sleep(0.3)
|
||||
return False
|
||||
|
||||
|
||||
def api(base, path, token, method="GET", body=None):
|
||||
"""Returns (http_status, parsed_body). 4xx is a result here, not an error —
|
||||
half of what this file checks is that the server says no."""
|
||||
req = urllib.request.Request(base + path, method=method)
|
||||
req.add_header("Cookie", "wp_session=" + token)
|
||||
req.add_header("Accept", "application/json")
|
||||
data = None
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode()
|
||||
req.add_header("Content-Type", "application/json")
|
||||
try:
|
||||
with urllib.request.urlopen(req, data, timeout=15) as r:
|
||||
return r.status, json.loads(r.read().decode() or "null")
|
||||
except urllib.error.HTTPError as e:
|
||||
try:
|
||||
return e.code, json.loads(e.read().decode() or "null")
|
||||
except Exception:
|
||||
return e.code, None
|
||||
|
||||
|
||||
def audit(base, tok, wp_id, action):
|
||||
_, rows = api(base, "/api/audit?entity_type=wp&entity_id=%s&action=%s" % (wp_id, action), tok)
|
||||
return rows or []
|
||||
|
||||
|
||||
# In-page dialog stubs. Installed once per load; __dlg records what fired so a
|
||||
# path that must be dialog-free can prove it, and __pReturn is the prompt answer.
|
||||
STUBS_JS = """(() => {
|
||||
window.__dlg = [];
|
||||
window.__pReturn = 'Boom lift arrives Friday - releasing to hold schedule';
|
||||
window.prompt = m => { window.__dlg.push(['prompt', String(m)]); return window.__pReturn; };
|
||||
window.alert = m => { window.__dlg.push(['alert', String(m)]); };
|
||||
window.confirm= m => { window.__dlg.push(['confirm',String(m)]); return false; };
|
||||
return true;
|
||||
})()"""
|
||||
|
||||
|
||||
def dlg(page):
|
||||
return json.loads(page.eval("JSON.stringify(window.__dlg||[])"))
|
||||
|
||||
|
||||
def reset_dlg(page):
|
||||
page.eval("window.__dlg=[]")
|
||||
|
||||
|
||||
def constraint_btn(page, index, which):
|
||||
"""Click Open/Cleared/N-A on constraint row `index` — the same buttons a
|
||||
user clicks, re-queried each time because buildConstraints() rebuilds them."""
|
||||
labels = {"open": "Open", "cleared": "Cleared", "na": "N/A"}
|
||||
page.eval("""(() => {
|
||||
const tr = document.querySelectorAll('#constraint-body tr')[%d];
|
||||
[...tr.querySelectorAll('.cstatus button')]
|
||||
.find(b => b.textContent.trim() === %s).click();
|
||||
})()""" % (index, json.dumps(labels[which])))
|
||||
settle(0.4)
|
||||
|
||||
|
||||
def click_status(page, val):
|
||||
page.eval("document.querySelector('#status-group .radio-pill[data-val=%s]').click()"
|
||||
% json.dumps(val))
|
||||
settle(0.4)
|
||||
|
||||
|
||||
def status_of(page):
|
||||
return page.eval("getRadio('status')")
|
||||
|
||||
|
||||
def strip_js_comments(src):
|
||||
"""Good enough for grepping: block comments, then line comments that are not
|
||||
inside a string (approximated by requiring the // not be preceded by : which
|
||||
covers the https:// case that bit BL-017)."""
|
||||
src = re.sub(r"/\*.*?\*/", "", src, flags=re.S)
|
||||
return "\n".join(re.sub(r"(?<![:'\"])//.*$", "", ln) for ln in src.split("\n"))
|
||||
|
||||
|
||||
def main():
|
||||
exe = cdp.find_browser()
|
||||
if not exe:
|
||||
print("no headless-capable browser found; set WP_BROWSER.")
|
||||
return 2
|
||||
|
||||
tmpdir = tempfile.mkdtemp(prefix="wpsuite-hold-")
|
||||
db_path = os.path.join(tmpdir, "check.db")
|
||||
server = None
|
||||
browser = None
|
||||
try:
|
||||
tok = seed(db_path)
|
||||
set_sop(db_path, {})
|
||||
port = cdp.free_port()
|
||||
base = "http://127.0.0.1:%d" % port
|
||||
server = start_server(port, db_path)
|
||||
root = tok["root"]
|
||||
|
||||
# ── 1. the clear-last-constraint path, driven in the browser ─────────
|
||||
print("\n1. CR-015: the stale hold")
|
||||
browser = cdp.Browser(exe)
|
||||
page = browser.page()
|
||||
page.clear_cookies()
|
||||
page.set_cookie("wp_session", root)
|
||||
page.viewport(1440, 900)
|
||||
page.goto(base + "/wp-creation-index.html?project=projA")
|
||||
dismiss_dialogs(page)
|
||||
chk("the creator boots", wait_creator(page))
|
||||
settle(1.5)
|
||||
page.eval(STUBS_JS)
|
||||
page.eval("window.__sentinel = 1")
|
||||
|
||||
n = page.eval("pkgConstraints.length")
|
||||
for i in range(n):
|
||||
constraint_btn(page, i, "cleared")
|
||||
offers = [d for d in dlg(page) if d[0] == "confirm" and "release-ready" in d[1]]
|
||||
chk("the release-ready offer on an unreleased package is unchanged",
|
||||
len(offers) == 1, ascii_(dlg(page)))
|
||||
reset_dlg(page)
|
||||
click_status(page, "In Progress")
|
||||
chk("with everything cleared the package moves to In Progress",
|
||||
status_of(page) == "In Progress", status_of(page))
|
||||
|
||||
constraint_btn(page, 0, "open")
|
||||
chk("flagging a constraint open on an in-progress package opens the hold modal",
|
||||
page.eval("document.getElementById('hold-modal').classList.contains('open')"))
|
||||
page.eval("document.getElementById('hold-details').value='Boom lift recalled for inspection'")
|
||||
page.eval("submitHold()")
|
||||
settle(0.4)
|
||||
chk("submitting the hold puts the package on hold", status_of(page) == "Issue",
|
||||
status_of(page))
|
||||
h = json.loads(page.eval("JSON.stringify(pkgHolds[pkgHolds.length-1]||{})"))
|
||||
chk("the hold entry records timestamp, user, and the status it interrupted",
|
||||
bool(h.get("ts")) and bool(h.get("by")) and h.get("from") == "In Progress",
|
||||
ascii_(h))
|
||||
|
||||
reset_dlg(page)
|
||||
constraint_btn(page, 0, "cleared")
|
||||
chk("clearing the last open constraint clears the hold",
|
||||
status_of(page) != "Issue", status_of(page))
|
||||
chk("...and returns the package to its PRIOR status, not to Issued and not to a guess",
|
||||
status_of(page) == "In Progress", status_of(page))
|
||||
chk("...with no dialog of any kind", dlg(page) == [], ascii_(dlg(page)))
|
||||
chk("...and no refresh — the same document is still running",
|
||||
page.eval("window.__sentinel") == 1)
|
||||
rel = json.loads(page.eval("JSON.stringify(pkgHolds[pkgHolds.length-1]||{})"))
|
||||
chk("the release is in the history with timestamp, user, and what cleared it",
|
||||
rel.get("released") is True and bool(rel.get("ts")) and bool(rel.get("by"))
|
||||
and rel.get("to") == "In Progress", ascii_(rel))
|
||||
|
||||
# Done-when items two and three: the same round trip, again.
|
||||
constraint_btn(page, 1, "open")
|
||||
page.eval("document.getElementById('hold-details').value='Second issue'")
|
||||
page.eval("submitHold()")
|
||||
settle(0.4)
|
||||
chk("logging a NEW constraint places it back on hold", status_of(page) == "Issue")
|
||||
constraint_btn(page, 1, "cleared")
|
||||
chk("clearing that constraint releases it again", status_of(page) == "In Progress",
|
||||
status_of(page))
|
||||
chk("the history now holds two hold/release pairs",
|
||||
page.eval("pkgHolds.filter(h=>h.released).length") == 2
|
||||
and page.eval("pkgHolds.filter(h=>!h.released).length") == 2,
|
||||
ascii_(page.eval("JSON.stringify(pkgHolds.map(h=>!!h.released))")))
|
||||
|
||||
# The return status must survive persistence, not live in a JS variable:
|
||||
# collect the package, put it back mid-hold, and clear.
|
||||
constraint_btn(page, 2, "open")
|
||||
page.eval("document.getElementById('hold-details').value='Third issue'")
|
||||
page.eval("submitHold()")
|
||||
settle(0.4)
|
||||
page.eval("""(() => {
|
||||
const p = collectPackage();
|
||||
loadPackageIntoForm(JSON.parse(JSON.stringify(p)));
|
||||
})()""")
|
||||
settle(0.8)
|
||||
chk("a saved-and-reloaded package is still on hold", status_of(page) == "Issue",
|
||||
status_of(page))
|
||||
constraint_btn(page, 2, "cleared")
|
||||
chk("...and clearing its constraint returns it to the status recorded on the hold",
|
||||
status_of(page) == "In Progress", status_of(page))
|
||||
|
||||
# ── 2. D4: the Urgent override ────────────────────────────────────────
|
||||
print("\n2. D4: Urgent surfaces the audited path; Normal and High do not")
|
||||
page.eval("newPackage()")
|
||||
settle(0.8)
|
||||
page.eval(STUBS_JS)
|
||||
chk("a new package starts with no override recorded",
|
||||
page.eval("pkgGateOverride === null || pkgGateOverride === undefined") is True
|
||||
or page.eval("!pkgGateOverride") is True)
|
||||
|
||||
chk("a Normal package with open constraints shows NO override button",
|
||||
page.eval("!document.querySelector('.rb-act')"))
|
||||
reset_dlg(page)
|
||||
click_status(page, "Issued")
|
||||
alerts = [d for d in dlg(page) if d[0] == "alert"]
|
||||
chk("...and the status control still hard-blocks it with the same refusal",
|
||||
len(alerts) == 1 and "still open" in alerts[0][1], ascii_(dlg(page)))
|
||||
chk("...and the status snapped back", status_of(page) == "Draft", status_of(page))
|
||||
|
||||
page.eval("document.getElementById('wp_priority').value='High'")
|
||||
constraint_btn(page, 0, "open") # any change redraws the banner
|
||||
chk("High is treated exactly like Normal — no override button",
|
||||
page.eval("!document.querySelector('.rb-act')"))
|
||||
|
||||
page.eval("document.getElementById('wp_priority').value='Urgent'")
|
||||
constraint_btn(page, 0, "open")
|
||||
chk("an Urgent package with open constraints offers the override as the "
|
||||
"banner's primary action, a real button",
|
||||
page.eval("(document.querySelector('.rb-act')||{}).tagName") == "BUTTON")
|
||||
|
||||
reset_dlg(page)
|
||||
page.eval("window.__pReturn = null") # back out first
|
||||
page.eval("document.querySelector('.rb-act').click()")
|
||||
settle(0.4)
|
||||
chk("backing out of the prompt releases nothing", status_of(page) == "Draft",
|
||||
status_of(page))
|
||||
chk("...and records no override", page.eval("!pkgGateOverride"))
|
||||
|
||||
reset_dlg(page)
|
||||
page.eval("window.__pReturn = 'Client directive 42 - install proceeds at risk'")
|
||||
page.eval("document.querySelector('.rb-act').click()")
|
||||
settle(0.4)
|
||||
prompts = [d for d in dlg(page) if d[0] == "prompt"]
|
||||
open_names = json.loads(page.eval(
|
||||
"JSON.stringify(pkgConstraints.filter(c=>c.status==='open').map(c=>c.name))"))
|
||||
chk("the prompt names every open constraint it is about to cross",
|
||||
len(prompts) == 1 and all(nm in prompts[0][1] for nm in open_names),
|
||||
ascii_(prompts))
|
||||
chk("taking the override releases the package", status_of(page) == "Issued",
|
||||
status_of(page))
|
||||
ov = json.loads(page.eval("JSON.stringify(pkgGateOverride||{})"))
|
||||
chk("the override records actor, timestamp, reason and the constraints it crossed",
|
||||
bool(ov.get("by")) and bool(ov.get("at"))
|
||||
and ov.get("reason", "").startswith("Client directive")
|
||||
and sorted(ov.get("constraints") or []) == sorted(open_names), ascii_(ov))
|
||||
|
||||
src = strip_js_comments(
|
||||
open(os.path.join(ROOT, "html", "wp-creation-app.js"), encoding="utf-8").read())
|
||||
chk("the client writes a gate override in exactly one place — confirmEarlyRelease()",
|
||||
src.count("pkgGateOverride={") == 1, src.count("pkgGateOverride={"))
|
||||
chk("the hold history has exactly two writers — the hold and the release",
|
||||
src.count("pkgHolds.push(") == 2, src.count("pkgHolds.push("))
|
||||
|
||||
# ── 3. the hold branches from every released state ────────────────────
|
||||
print("\n3. Issue (Hold) is a branch, not a step")
|
||||
page.eval("newPackage()")
|
||||
settle(0.8)
|
||||
page.eval(STUBS_JS)
|
||||
n = page.eval("pkgConstraints.length")
|
||||
for i in range(n):
|
||||
constraint_btn(page, i, "na")
|
||||
reset_dlg(page)
|
||||
for st in ("Issued", "In Progress", "QC"):
|
||||
click_status(page, st)
|
||||
click_status(page, "Issue")
|
||||
opened = page.eval("document.getElementById('hold-modal').classList.contains('open')")
|
||||
page.eval("cancelHold()")
|
||||
settle(0.3)
|
||||
chk("from %s: Issue (hold) opens the log modal, and cancel restores %s" % (st, st),
|
||||
opened and status_of(page) == st, status_of(page))
|
||||
|
||||
page.close()
|
||||
browser.close()
|
||||
browser = None
|
||||
|
||||
# ── 4. the server refuses what the browser refuses, and writes history ─
|
||||
print("\n4. the server: gates and history")
|
||||
CON = lambda st: [{"name": "Boom lift", "status": st, "comment": ""},
|
||||
{"name": "Permits", "status": "cleared", "comment": ""}]
|
||||
|
||||
code, _ = api(base, "/api/wps", root, "POST", {
|
||||
"id": "wpN1", "project_id": "projA", "number": "N-1", "subject": "normal",
|
||||
"status": "Issued", "data": {"priority": "Normal", "constraints": CON("open")}})
|
||||
chk("Normal + open constraint: refused", code == 409, code)
|
||||
|
||||
code, _ = api(base, "/api/wps", root, "POST", {
|
||||
"id": "wpU1", "project_id": "projA", "number": "U-1", "subject": "urgent bare",
|
||||
"status": "Issued", "data": {"priority": "Urgent", "constraints": CON("open")}})
|
||||
chk("Urgent with NO override: refused — urgency alone is not a reason", code == 409, code)
|
||||
|
||||
ov = {"reason": "Client directive 42", "at": "2026-08-19T00:00:00Z", "by": "Root"}
|
||||
code, _ = api(base, "/api/wps", root, "POST", {
|
||||
"id": "wpU2", "project_id": "projA", "number": "U-2", "subject": "urgent unnamed",
|
||||
"status": "Issued",
|
||||
"data": {"priority": "Urgent", "constraints": CON("open"), "gateOverride": ov}})
|
||||
chk("Urgent + override that names nothing: refused — it must say what it covers",
|
||||
code == 409, code)
|
||||
|
||||
code, _ = api(base, "/api/wps", root, "POST", {
|
||||
"id": "wpU3", "project_id": "projA", "number": "U-3", "subject": "urgent wrong name",
|
||||
"status": "Issued",
|
||||
"data": {"priority": "Urgent", "constraints": CON("open"),
|
||||
"gateOverride": dict(ov, constraints=["Permits"])}})
|
||||
chk("Urgent + override naming a DIFFERENT constraint: refused — no riding through "
|
||||
"on an old reason", code == 409, code)
|
||||
|
||||
code, _ = api(base, "/api/wps", root, "POST", {
|
||||
"id": "wpN2", "project_id": "projA", "number": "N-2", "subject": "normal named",
|
||||
"status": "Issued",
|
||||
"data": {"priority": "Normal", "constraints": CON("open"),
|
||||
"gateOverride": dict(ov, constraints=["Boom lift"])}})
|
||||
chk("Normal + a fully-named override: still refused — the path is Urgent-only",
|
||||
code == 409, code)
|
||||
|
||||
code, _ = api(base, "/api/wps", root, "POST", {
|
||||
"id": "wpU4", "project_id": "projA", "number": "U-4", "subject": "urgent covered",
|
||||
"status": "Issued",
|
||||
"data": {"priority": "Urgent", "constraints": CON("open"),
|
||||
"gateOverride": dict(ov, constraints=["Boom lift"])}})
|
||||
chk("Urgent + an override naming the open constraint: released", code == 200, code)
|
||||
rows = audit(base, root, "wpU4", "gate_overridden")
|
||||
chk("...and the audit log names the constraints it crossed",
|
||||
rows and "Boom lift" in ((rows[0].get("detail") or {}).get("constraints") or []),
|
||||
ascii_(rows[:1]))
|
||||
|
||||
# A1: the predecessor gate is untouched — refusable only with a reason.
|
||||
api(base, "/api/wps", root, "POST", {
|
||||
"id": "wpPred", "project_id": "projA", "number": "P-0", "subject": "upstream",
|
||||
"status": "In Progress", "data": {"constraints": CON("cleared")}})
|
||||
code, _ = api(base, "/api/wps", root, "POST", {
|
||||
"id": "wpA1c", "project_id": "projA", "number": "A1-1", "subject": "downstream",
|
||||
"status": "Issued",
|
||||
"data": {"constraints": CON("cleared"), "predecessors": ["wpPred"]}})
|
||||
chk("A1: an unclosed predecessor still refuses a release", code == 409, code)
|
||||
code, _ = api(base, "/api/wps", root, "POST", {
|
||||
"id": "wpA1c", "project_id": "projA", "number": "A1-1", "subject": "downstream",
|
||||
"status": "Issued",
|
||||
"data": {"constraints": CON("cleared"), "predecessors": ["wpPred"],
|
||||
"gateOverride": {"reason": "Staged ahead of turnover", "by": "Root",
|
||||
"at": "2026-08-19T00:00:00Z"}}})
|
||||
chk("A1: the reasoned override still releases", code == 200, code)
|
||||
chk("A1: and still logs", bool(audit(base, root, "wpA1c", "gate_overridden")))
|
||||
|
||||
# The hold history, through the upsert (how the browser and outbox save).
|
||||
api(base, "/api/wps", root, "POST", {
|
||||
"id": "wpH1", "project_id": "projA", "number": "H-1", "subject": "hold history",
|
||||
"status": "In Progress", "data": {"constraints": CON("cleared")}})
|
||||
code, _ = api(base, "/api/wps", root, "POST", {
|
||||
"id": "wpH1", "project_id": "projA", "number": "H-1", "subject": "hold history",
|
||||
"status": "Issue",
|
||||
"data": {"constraints": CON("open"),
|
||||
"holds": [{"ts": "2026-08-19T01:00:00Z", "constraint": "Boom lift",
|
||||
"details": "Lift recalled for inspection", "by": "Root",
|
||||
"from": "In Progress"}]}})
|
||||
rows = audit(base, root, "wpH1", "hold_logged")
|
||||
chk("going on hold writes hold_logged with the reason and the interrupted status",
|
||||
code == 200 and rows
|
||||
and (rows[0].get("detail") or {}).get("reason") == "Lift recalled for inspection"
|
||||
and (rows[0].get("detail") or {}).get("from") == "In Progress",
|
||||
ascii_((code, rows[:1])))
|
||||
code, _ = api(base, "/api/wps", root, "POST", {
|
||||
"id": "wpH1", "project_id": "projA", "number": "H-1", "subject": "hold history",
|
||||
"status": "In Progress",
|
||||
"data": {"constraints": CON("cleared"),
|
||||
"holds": [{"ts": "2026-08-19T01:00:00Z", "constraint": "Boom lift",
|
||||
"details": "Lift recalled for inspection", "by": "Root",
|
||||
"from": "In Progress"},
|
||||
{"ts": "2026-08-19T02:00:00Z", "released": True,
|
||||
"constraint": "Boom lift", "by": "Root",
|
||||
"details": "Hold released - last open constraint cleared",
|
||||
"to": "In Progress"}]}})
|
||||
rows = audit(base, root, "wpH1", "hold_released")
|
||||
chk("coming off hold writes hold_released with where it went back to",
|
||||
code == 200 and rows
|
||||
and (rows[0].get("detail") or {}).get("to") == "In Progress",
|
||||
ascii_((code, rows[:1])))
|
||||
|
||||
# The /status endpoint is not a side door: same gates, same history.
|
||||
api(base, "/api/wps", root, "POST", {
|
||||
"id": "wpS1", "project_id": "projA", "number": "S-1", "subject": "endpoint",
|
||||
"status": "In Progress", "data": {"constraints": CON("cleared")}})
|
||||
code, _ = api(base, "/api/wps/wpS1/status", root, "POST", {"status": "Issue"})
|
||||
chk("/status can place a package on hold", code == 200, code)
|
||||
chk("...and writes hold_logged", bool(audit(base, root, "wpS1", "hold_logged")))
|
||||
api(base, "/api/wps", root, "POST", {
|
||||
"id": "wpS1", "project_id": "projA", "number": "S-1", "subject": "endpoint",
|
||||
"status": "Issue", "data": {"constraints": CON("open")}})
|
||||
code, _ = api(base, "/api/wps/wpS1/status", root, "POST", {"status": "Issued"})
|
||||
chk("/status cannot walk a held package past its open constraint", code == 409, code)
|
||||
api(base, "/api/wps", root, "POST", {
|
||||
"id": "wpS1", "project_id": "projA", "number": "S-1", "subject": "endpoint",
|
||||
"status": "Issue", "data": {"constraints": CON("cleared")}})
|
||||
code, _ = api(base, "/api/wps/wpS1/status", root, "POST", {"status": "In Progress"})
|
||||
chk("/status releases once the record is clear", code == 200, code)
|
||||
chk("...and writes hold_released", bool(audit(base, root, "wpS1", "hold_released")))
|
||||
|
||||
# Grep half of D4's "no unwritten release": every status assignment in
|
||||
# app.py sits in a function that runs the gates. (Constructor-built rows
|
||||
# in seed scripts are out of scope — S13 owns seeding.)
|
||||
tree = ast.parse(open(os.path.join(ROOT, "server", "app.py"), encoding="utf-8").read())
|
||||
bad = []
|
||||
for fn in [n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)]:
|
||||
assigns = [n for n in ast.walk(fn) if isinstance(n, ast.Assign)
|
||||
and any(isinstance(t, ast.Attribute) and t.attr == "status"
|
||||
for t in n.targets)]
|
||||
if not assigns:
|
||||
continue
|
||||
calls = {c.func.id for c in ast.walk(fn)
|
||||
if isinstance(c, ast.Call) and isinstance(c.func, ast.Name)}
|
||||
if "enforce_release_gates" not in calls:
|
||||
bad.append(fn.name)
|
||||
chk("every status assignment in server/app.py is inside a function that runs "
|
||||
"the release gates", not bad, ascii_(bad))
|
||||
|
||||
finally:
|
||||
if browser is not None:
|
||||
try:
|
||||
browser.close()
|
||||
except Exception:
|
||||
pass
|
||||
if server is not None:
|
||||
try:
|
||||
server.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print("\n" + "-" * 54)
|
||||
print("%d/%d checks passed." % (len(_PASS), len(_PASS) + len(_FAIL)))
|
||||
for f in _FAIL:
|
||||
print(" - " + f)
|
||||
return 1 if _FAIL else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user