@windyroad/risk-scorer 0.16.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -310,5 +310,5 @@
310
310
  }
311
311
  },
312
312
  "name": "wr-risk-scorer",
313
- "version": "0.16.0"
313
+ "version": "0.16.1"
314
314
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windyroad/risk-scorer",
3
- "version": "0.16.0",
3
+ "version": "0.16.1",
4
4
  "description": "Pipeline risk scoring, commit/push gates, and secret leak detection",
5
5
  "bin": {
6
6
  "windyroad-risk-scorer": "./bin/install.mjs"
@@ -41,7 +41,10 @@
41
41
  # - Resolves the ticket file via dual-tolerant glob (ADR-031 + RFC-002):
42
42
  # docs/problems/<NNN>-*.md (flat) AND docs/problems/*/<NNN>-*.md (per-state)
43
43
  # - Extracts the Priority value from the ticket's `**Priority**: N (...)` line.
44
- # - Detects Rule 2 VP carve-out (ticket file ends in .verifying.md).
44
+ # - Detects Rule 2 VP carve-out (ticket file ends in .verifying.md) — narrowed
45
+ # per P398: a verifying ticket graduates (resolved) when its `## Fix Released`
46
+ # section is populated (code already shipped, only the changelog is held);
47
+ # it stays vp-blocked only while the fix is not yet shipped.
45
48
  # - Parses docs/changesets-holding/README.md "Currently held" section and
46
49
  # groups entries by normalised reinstate-trigger prose (Phase 2b).
47
50
  # - Multi-member groups emit class=3b + cohort=<id> with cohort-level
@@ -121,6 +124,12 @@ held_files = sys.argv[3:]
121
124
  FILENAME_TICKET_RE = re.compile(r'-p(\d+)-', re.IGNORECASE)
122
125
  BODY_TICKET_RE = re.compile(r'\bP(\d+)\b')
123
126
  PRIORITY_LINE_RE = re.compile(r'^\*\*Priority\*\*:\s*(\d+)\b')
127
+ FIX_RELEASED_HEADING_RE = re.compile(r'^##\s+Fix Released\s*$', re.IGNORECASE)
128
+ # Placeholder-only tokens that do NOT count as a populated `## Fix Released`
129
+ # section (a fix that is scaffolded-but-not-shipped). Kept mechanical per
130
+ # ADR-015 — this is deterministic detection, not prose judgement.
131
+ FIX_RELEASED_PLACEHOLDERS = frozenset({'tbd', 'n/a', 'na', 'pending', '(pending)',
132
+ '(deferred)', 'deferred', '(none)', 'none'})
124
133
 
125
134
  # Phase 2b — README "Currently held" bullet parser.
126
135
  # Matches `- \`<filename>\` ... **Reinstate trigger**: <trigger-text>`.
@@ -168,6 +177,39 @@ def extract_priority(ticket_path: str):
168
177
  return None
169
178
 
170
179
 
180
+ def fix_shipped(ticket_path: str) -> bool:
181
+ """True when the ticket carries a populated `## Fix Released` section.
182
+
183
+ A verifying ticket whose fix has shipped carries real prose under
184
+ `## Fix Released` (per ADR-022 / manage-problem closing contract); an
185
+ absent, empty, or placeholder-only section means the fix has not actually
186
+ shipped yet. Mechanical predicate per ADR-015 (ADR-061 Rule 2 amended,
187
+ P398): section present AND ≥1 non-blank line that is not a placeholder
188
+ token counts as shipped.
189
+ """
190
+ try:
191
+ with open(ticket_path, 'r', encoding='utf-8') as f:
192
+ lines = f.readlines()
193
+ except (OSError, IOError):
194
+ return False
195
+ in_section = False
196
+ for line in lines:
197
+ if FIX_RELEASED_HEADING_RE.match(line.strip()):
198
+ in_section = True
199
+ continue
200
+ if not in_section:
201
+ continue
202
+ stripped = line.strip()
203
+ if stripped.startswith('## '):
204
+ break # next section reached with no real content
205
+ if not stripped:
206
+ continue
207
+ if stripped.lower() in FIX_RELEASED_PLACEHOLDERS:
208
+ continue
209
+ return True # real content under the heading
210
+ return False
211
+
212
+
171
213
  def resolve_ticket_ids(changeset_path: str):
172
214
  """Apply Rule 1a join: filename convention primary, body-grep fallback.
173
215
 
@@ -325,7 +367,7 @@ for changeset_path in held_files:
325
367
  priority = extract_priority(path)
326
368
  if priority is None:
327
369
  continue
328
- resolutions.append((tid, priority, suffix))
370
+ resolutions.append((tid, priority, suffix, path))
329
371
 
330
372
  if not resolutions:
331
373
  per_changeset[basename] = {
@@ -336,14 +378,20 @@ for changeset_path in held_files:
336
378
  continue
337
379
 
338
380
  resolutions.sort(key=lambda r: r[1], reverse=True)
339
- chosen_tid, chosen_priority, chosen_suffix = resolutions[0]
340
- if chosen_suffix == 'verifying':
381
+ chosen_tid, chosen_priority, chosen_suffix, chosen_path = resolutions[0]
382
+ if chosen_suffix == 'verifying' and not fix_shipped(chosen_path):
383
+ # ADR-061 Rule 2 (narrowed per P398): the VP carve-out holds a changeset
384
+ # only while its fix is NOT yet shipped. A verifying ticket with no
385
+ # populated `## Fix Released` section is still mid-flight — hold it.
341
386
  per_changeset[basename] = {
342
387
  'ticket_label': f'P{chosen_tid}',
343
388
  'priority': chosen_priority,
344
389
  'status': 'vp-blocked',
345
390
  }
346
391
  continue
392
+ # A verifying ticket whose `## Fix Released` is populated means the code is
393
+ # already live on npm; only the changelog-attribution changeset is held, so
394
+ # it graduates as `resolved` rather than stranding indefinitely (P398).
347
395
 
348
396
  per_changeset[basename] = {
349
397
  'ticket_label': f'P{chosen_tid}',
@@ -202,6 +202,66 @@ EOF
202
202
  echo "$output" | grep -q 'GRADUATION_SUMMARY: total=1 resolved=0 vp_blocked=1 halts=0'
203
203
  }
204
204
 
205
+ # ----- P398: Rule 2 narrowed — shipped verifying graduates, unshipped holds -----
206
+
207
+ # Case (h.1) — verifying ticket WITH populated `## Fix Released` graduates (code shipped)
208
+ @test "case (h.1): shipped verifying (populated Fix Released) → resolved (P398)" {
209
+ seed_problem "310" "verifying" "9" "## Fix Released
210
+
211
+ Deployed in v0.42.0. Awaiting user verification."
212
+ seed_changeset "wr-itil-p310-shipped.md"
213
+ run bash "$SCRIPT" "$WORK_DIR"
214
+ [ "$status" -eq 0 ]
215
+ echo "$output" | grep -q 'changeset=wr-itil-p310-shipped.md'
216
+ echo "$output" | grep -q 'ticket=P310'
217
+ echo "$output" | grep -q 'status=resolved'
218
+ echo "$output" | grep -q 'GRADUATION_SUMMARY: total=1 resolved=1 vp_blocked=0 halts=0'
219
+ }
220
+
221
+ # Case (h.2) — verifying ticket with placeholder-only `## Fix Released` stays held
222
+ @test "case (h.2): unshipped verifying (placeholder Fix Released) → vp-blocked (P398)" {
223
+ seed_problem "311" "verifying" "9" "## Fix Released
224
+
225
+ (pending)"
226
+ seed_changeset "wr-itil-p311-pending.md"
227
+ run bash "$SCRIPT" "$WORK_DIR"
228
+ [ "$status" -eq 0 ]
229
+ echo "$output" | grep -q 'ticket=P311'
230
+ echo "$output" | grep -q 'status=vp-blocked'
231
+ }
232
+
233
+ # Case (h.3) — verifying ticket with NO `## Fix Released` section stays held (case (e) invariant)
234
+ @test "case (h.3): verifying with no Fix Released section → vp-blocked (P398 unshipped branch)" {
235
+ seed_problem "312" "verifying" "9"
236
+ seed_changeset "wr-itil-p312-no-section.md"
237
+ run bash "$SCRIPT" "$WORK_DIR"
238
+ [ "$status" -eq 0 ]
239
+ echo "$output" | grep -q 'ticket=P312'
240
+ echo "$output" | grep -q 'status=vp-blocked'
241
+ }
242
+
243
+ # Case (h.4) — an all-shipped-verifying cohort rolls up to resolved and graduates atomically
244
+ @test "case (h.4): cohort of all-shipped-verifying members rolls up to resolved (P398)" {
245
+ seed_problem "320" "verifying" "9" "## Fix Released
246
+
247
+ Shipped in v0.50.0."
248
+ seed_problem "321" "verifying" "12" "## Fix Released
249
+
250
+ Shipped in v0.50.0."
251
+ seed_changeset "wr-itil-p320-slice-a.md"
252
+ seed_changeset "wr-itil-p321-slice-b.md"
253
+ seed_holding_readme \
254
+ "- \`wr-itil-p320-slice-a.md\` — patch. **Reinstate trigger**: shipped cohort graduates." \
255
+ "- \`wr-itil-p321-slice-b.md\` — patch. **Reinstate trigger**: shipped cohort graduates."
256
+ run bash "$SCRIPT" "$WORK_DIR"
257
+ [ "$status" -eq 0 ]
258
+ # Both members class=3b, both resolved (shipped), cohort priority = max(9,12) = 12
259
+ echo "$output" | grep 'changeset=wr-itil-p320-slice-a.md' | grep -q 'class=3b'
260
+ echo "$output" | grep 'changeset=wr-itil-p320-slice-a.md' | grep -q 'status=resolved'
261
+ echo "$output" | grep 'changeset=wr-itil-p321-slice-b.md' | grep -q 'status=resolved'
262
+ echo "$output" | grep 'changeset=wr-itil-p320-slice-a.md' | grep -q 'priority=12'
263
+ }
264
+
205
265
  # Case (f) — status-agnostic Priority lookup (open, known-error, closed all resolve)
206
266
  @test "case (f.1): open ticket resolves" {
207
267
  seed_problem "400" "open" "8"