@dev-loops/core 0.6.0 → 0.7.2

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.
@@ -11,7 +11,7 @@
11
11
  *
12
12
  * This module owns:
13
13
  * - canonical section-name matching for AC / DoD blocks
14
- * - bullet-item extraction (both `- [ ]` and `- [x]`)
14
+ * - bullet-item extraction (checklist `- [ ]`/`- [x]` and top-level `- ` bullets)
15
15
  * - linked-refinement-doc detection from issue body
16
16
  *
17
17
  * It deliberately does NOT:
@@ -47,9 +47,44 @@ const DOD_SECTION_PATTERNS = Object.freeze([
47
47
  /^dod\s*$/i,
48
48
  ]);
49
49
 
50
+ /**
51
+ * Fenced-code-span tracker. Given the previous fence state and the current
52
+ * line, returns { fence, insideFence } where:
53
+ * - `fence` is the next state ({ char, len } while open, else null)
54
+ * - `insideFence` is true when the line's CONTENT is inside a code span
55
+ * (i.e. a fence line, or a line between an open and its close)
56
+ *
57
+ * CommonMark: an N-marker fence (``` or ~~~) closes only on a line of >= N
58
+ * markers of the SAME char with no info string. This is the single source of
59
+ * truth shared by parseMarkdownSections (headings) and extractChecklistItems
60
+ * (checkboxes) so the two anti-spoof layers cannot drift (issue #1025).
61
+ */
62
+ function stepFence(fence, line) {
63
+ const openMatch = /^\s*(`{3,}|~{3,})/u.exec(line);
64
+ if (openMatch) {
65
+ const char = openMatch[1][0];
66
+ const len = openMatch[1].length;
67
+ // A closing fence is a bare run of >= N markers of ONLY the opening char
68
+ // (CommonMark: no mixed markers, no info string).
69
+ const isBareRun = new RegExp(`^\\s*${char}+\\s*$`, "u").test(line);
70
+ if (fence === null) {
71
+ return { fence: { char, len }, insideFence: true };
72
+ }
73
+ if (fence.char === char && len >= fence.len && isBareRun) {
74
+ return { fence: null, insideFence: true };
75
+ }
76
+ return { fence, insideFence: true };
77
+ }
78
+ return { fence, insideFence: fence !== null };
79
+ }
80
+
50
81
  /**
51
82
  * Extract `## ...` heading boundaries from a Markdown body.
52
83
  * Returns a sorted array of { level, name, bodyLines } records.
84
+ *
85
+ * Headings inside a fenced code span (``` or ~~~) are NOT treated as headings —
86
+ * otherwise a body could spoof the refinement/spec gate with real-looking
87
+ * headings that carry no real spec (gate integrity, issue #1025).
53
88
  */
54
89
  export function parseMarkdownSections(body) {
55
90
  if (typeof body !== "string" || body.length === 0) {
@@ -59,8 +94,15 @@ export function parseMarkdownSections(body) {
59
94
  const lines = body.split(/\r?\n/u);
60
95
  const sections = [];
61
96
  let current = null;
97
+ let fence = null;
62
98
 
63
99
  for (const line of lines) {
100
+ const step = stepFence(fence, line);
101
+ fence = step.fence;
102
+ if (step.insideFence) {
103
+ if (current) current.bodyLines.push(line);
104
+ continue;
105
+ }
64
106
  const match = /^(#{1,6})\s+(.+?)\s*$/u.exec(line);
65
107
  if (match) {
66
108
  if (current) {
@@ -97,10 +139,18 @@ function findSectionByPatterns(sections, patterns) {
97
139
  }
98
140
 
99
141
  /**
100
- * Extract checklist bullet items (`- [ ]` and `- [x]`) from a section body.
101
- * Returns the trimmed item text for each matching line. The checkbox state
102
- * (checked vs unchecked) is intentionally not preserved: callers only need
103
- * the item text to satisfy the refinement-artifact contract.
142
+ * Extract bullet items from a section body. Counts both `- [ ]`/`- [x]`
143
+ * checklist items and top-level plain `- ` bullets (dash at column 0, so
144
+ * nested/indented sub-bullets are not counted). Empty checkbox placeholders
145
+ * (`- [ ]` / `- [x]` with no trailing text) are skipped, not counted, so a
146
+ * section of only unfilled placeholders reports as unrefined. Returns the
147
+ * trimmed item text for each matching line. The checkbox state (checked vs
148
+ * unchecked) is intentionally not preserved: callers only need the item
149
+ * text to satisfy the refinement-artifact contract.
150
+ *
151
+ * This is only ever called on the body of an already-recognized AC/DoD
152
+ * section (see `detectIssueRefinementArtifact`), so counting plain bullets
153
+ * is scoped to those sections and never affects prose sections.
104
154
  */
105
155
  export function extractChecklistItems(sectionBody) {
106
156
  if (typeof sectionBody !== "string" || sectionBody.length === 0) {
@@ -109,11 +159,33 @@ export function extractChecklistItems(sectionBody) {
109
159
 
110
160
  const items = [];
111
161
  const lines = sectionBody.split(/\r?\n/u);
162
+ let fence = null;
112
163
 
113
164
  for (const line of lines) {
114
- const match = /^\s*-\s+\[(?:[ xX])\]\s+(.+?)\s*$/u.exec(line);
115
- if (match) {
116
- const text = match[1].trim();
165
+ // Checkboxes/bullets inside a fenced code span are non-interactive text, not
166
+ // real items — skip them so a body cannot spoof the AC/DoD gate with
167
+ // code-fenced checkboxes (issue #1025). Same fence logic as parseMarkdownSections.
168
+ const step = stepFence(fence, line);
169
+ fence = step.fence;
170
+ if (step.insideFence) {
171
+ continue;
172
+ }
173
+ // Checklist item: `- [ ]` / `- [x]` (leading indentation tolerated).
174
+ // Consume ANY checkbox-marker line here; push only when it carries text,
175
+ // so empty placeholders (`- [ ]`) are skipped rather than counted.
176
+ const checkboxMatch = /^\s*-\s+\[(?:[ xX])\](?:\s+(.+?))?\s*$/u.exec(line);
177
+ if (checkboxMatch) {
178
+ const text = (checkboxMatch[1] ?? "").trim();
179
+ if (text.length > 0) {
180
+ items.push(text);
181
+ }
182
+ continue;
183
+ }
184
+ // Top-level plain bullet: dash at column 0, space required (so `---`
185
+ // horizontal rules and `-x` do not match; indented sub-bullets do not).
186
+ const bulletMatch = /^-\s+(.+?)\s*$/u.exec(line);
187
+ if (bulletMatch) {
188
+ const text = bulletMatch[1].trim();
117
189
  if (text.length > 0) {
118
190
  items.push(text);
119
191
  }
@@ -248,6 +320,185 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null }
248
320
  };
249
321
  }
250
322
 
323
+ /**
324
+ * PR-body-as-spec invariant sections (issue #1025, lightweight path).
325
+ *
326
+ * When a lightweight session uses the PR description itself as the
327
+ * spec-of-record (no committed phase/plan doc), the PR body must still carry
328
+ * the same invariants a durable spec doc would. AC/DoD reuse the checklist
329
+ * patterns above; these are the narrative sections not covered by those.
330
+ * Key order = validation/report order. Each key maps to its distinct
331
+ * `missing_*` code (mirrors `checkBaseSections` in _refine-helpers.mjs).
332
+ */
333
+ export const PR_BODY_SPEC_NARRATIVE_SECTIONS = Object.freeze({
334
+ objective: {
335
+ code: "missing_objective",
336
+ label: "Objective/why",
337
+ patterns: [/^objective\b/iu, /^why\b/iu, /^goals?\b/iu, /^summary\b/iu, /^problem\b/iu],
338
+ },
339
+ in_scope: {
340
+ code: "missing_in_scope",
341
+ label: "In scope",
342
+ patterns: [/^in[- ]?scope\b/iu, /^scope\b/iu],
343
+ },
344
+ non_goals: {
345
+ code: "missing_explicit_non_goals",
346
+ label: "Explicit non-goals",
347
+ patterns: [/^explicit non-?goals\b/iu, /^non-?goals\b/iu, /^out of scope\b/iu],
348
+ },
349
+ open_questions: {
350
+ code: "missing_open_questions",
351
+ label: "Open questions/risks",
352
+ patterns: [/^open questions\b/iu, /^risks?\b/iu, /^questions\b/iu],
353
+ },
354
+ });
355
+
356
+ /**
357
+ * GitHub's accepted closing-keyword issue references (close/closes/closed,
358
+ * fix/fixes/fixed, resolve/resolves/resolved), case-insensitive, followed by
359
+ * `#N` or the cross-repo `owner/repo#N` form. Mirrors the linkage the
360
+ * lightweight path (#1025) requires the PR body to carry (issue #1181: five
361
+ * lightweight PRs merged without this and none auto-closed their issue).
362
+ */
363
+ const CLOSING_ISSUE_REFERENCE_PATTERN =
364
+ /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+(?:[\w.-]+\/[\w.-]+)?#(\d+)/giu;
365
+
366
+ function extractClosingIssueNumbers(body) {
367
+ // Same fence-skip as sectionHasBody: a `Closes #N` line quoted inside a
368
+ // ```fenced``` example (e.g. a PR-template sample) must not spoof the gate.
369
+ let fence = null;
370
+ const unfenced = [];
371
+ for (const line of body.split("\n")) {
372
+ const step = stepFence(fence, line);
373
+ fence = step.fence;
374
+ if (step.insideFence) continue;
375
+ unfenced.push(line);
376
+ }
377
+ // Inline `code` spans don't auto-close on GitHub either: blank out any
378
+ // backtick-run-delimited span (equal-length runs pair, so ``a `b` c`` works).
379
+ // ponytail: not full CommonMark span matching; an unbalanced stray backtick
380
+ // over-strips toward fail-closed, which is the safe direction for this gate.
381
+ const text = unfenced.join("\n").replace(/(`+)[\s\S]*?\1/gu, " ");
382
+ const seen = new Set();
383
+ const numbers = [];
384
+ for (const match of text.matchAll(CLOSING_ISSUE_REFERENCE_PATTERN)) {
385
+ const n = Number(match[1]);
386
+ if (Number.isInteger(n) && n > 0 && !seen.has(n)) {
387
+ seen.add(n);
388
+ numbers.push(n);
389
+ }
390
+ }
391
+ return numbers;
392
+ }
393
+
394
+ function sectionHasBody(section) {
395
+ // A real body needs >=1 non-whitespace line OUTSIDE any fenced code span —
396
+ // a section whose only content is a ```fenced``` block is treated as empty so
397
+ // it cannot spoof the narrative-invariant gate (issue #1025, same stepFence as
398
+ // parseMarkdownSections + extractChecklistItems).
399
+ if (!section) return false;
400
+ let fence = null;
401
+ for (const line of section.bodyLines) {
402
+ const step = stepFence(fence, line);
403
+ fence = step.fence;
404
+ if (step.insideFence) continue;
405
+ if (line.trim().length > 0) return true;
406
+ }
407
+ return false;
408
+ }
409
+
410
+ /**
411
+ * Validate that a PR body carries every invariant required to serve as the
412
+ * lightweight spec-of-record: Objective/why, in-scope, explicit non-goals,
413
+ * testable Acceptance criteria (>=1 checklist item), Definition of done
414
+ * (>=1 checklist item), Open questions/risks, and — unless explicit
415
+ * issue-less mode is requested — a GitHub closing-keyword issue reference
416
+ * (`Closes #N` and GitHub's other accepted forms — the lightweight path's
417
+ * `Closes #N` linkage, issue #1181). Reuses the generic markdown logic
418
+ * (parseMarkdownSections / AC + DoD patterns / extractChecklistItems) so
419
+ * there is no parallel validator. Fails closed: every missing invariant is
420
+ * reported under its distinct `missing_*` code. Pure; no side effects.
421
+ *
422
+ * Issue-less mode (`issueLess: true`, issue #1210): the narrative invariants
423
+ * stay unconditional, but the closing-issue linkage flips from REQUIRED to
424
+ * FORBIDDEN — the PR is the sole artifact, so it MUST NOT carry a closing
425
+ * reference to an issue that doesn't back it. A present reference in this
426
+ * mode fails closed under `unexpected_closing_issue_reference`, distinct
427
+ * from `missing_closing_issue_reference` (tracker-backed mode, the default)
428
+ * so callers can tell "no issue expected" apart from "issue expected but
429
+ * absent". `expectedIssue` and `issueLess` are mutually exclusive; callers
430
+ * pick exactly one mode (tracker-backed, with or without a specific
431
+ * expected issue) or issue-less — never both.
432
+ *
433
+ * @param {{ body?: string, expectedIssue?: number, issueLess?: boolean }} input
434
+ * @returns {{ checker: "validate-pr-body-spec", ok: boolean, errors: { code: string, message: string }[], sections: string[], acItems: string[], dodItems: string[], closesIssues: number[] }}
435
+ */
436
+ export function validatePrBodySpec({ body = "", expectedIssue = null, issueLess = false } = {}) {
437
+ if (issueLess && Number.isInteger(expectedIssue)) {
438
+ // Fail closed at the library boundary too (not just the CLI): the two modes
439
+ // are contradictory and silently preferring one would hide caller bugs.
440
+ throw new Error("validatePrBodySpec: issueLess and expectedIssue are mutually exclusive; pass exactly one issue-linkage mode");
441
+ }
442
+ const bodyText = typeof body === "string" ? body : "";
443
+ const sections = parseMarkdownSections(bodyText);
444
+ const errors = [];
445
+
446
+ for (const { code, label, patterns } of Object.values(PR_BODY_SPEC_NARRATIVE_SECTIONS)) {
447
+ const section = findSectionByPatterns(sections, patterns);
448
+ if (!sectionHasBody(section)) {
449
+ errors.push({ code, message: `Missing or empty ${label} section.` });
450
+ }
451
+ }
452
+
453
+ const acSection = findSectionByPatterns(sections, ACCEPTANCE_SECTION_PATTERNS);
454
+ const acItems = acSection ? extractChecklistItems(acSection.bodyLines.join("\n")) : [];
455
+ if (acItems.length === 0) {
456
+ errors.push({
457
+ code: "missing_acceptance_criteria",
458
+ message: "Missing testable Acceptance criteria (no checklist items found).",
459
+ });
460
+ }
461
+
462
+ const dodSection = findSectionByPatterns(sections, DOD_SECTION_PATTERNS);
463
+ const dodItems = dodSection ? extractChecklistItems(dodSection.bodyLines.join("\n")) : [];
464
+ if (dodItems.length === 0) {
465
+ errors.push({
466
+ code: "missing_definition_of_done",
467
+ message: "Missing Definition of done (no checklist items found).",
468
+ });
469
+ }
470
+
471
+ const closesIssues = extractClosingIssueNumbers(bodyText);
472
+ if (issueLess) {
473
+ if (closesIssues.length > 0) {
474
+ errors.push({
475
+ code: "unexpected_closing_issue_reference",
476
+ message: `Issue-less PR body MUST NOT carry a closing reference to an issue that doesn't back it (found ${closesIssues.map((n) => `#${n}`).join(", ")}).`,
477
+ });
478
+ }
479
+ } else if (closesIssues.length === 0) {
480
+ errors.push({
481
+ code: "missing_closing_issue_reference",
482
+ message: "Missing a GitHub closing-keyword issue reference (e.g. `Closes #123`).",
483
+ });
484
+ } else if (Number.isInteger(expectedIssue) && !closesIssues.includes(expectedIssue)) {
485
+ errors.push({
486
+ code: "closes_wrong_issue",
487
+ message: `PR body closes ${closesIssues.map((n) => `#${n}`).join(", ")}, not the expected #${expectedIssue}.`,
488
+ });
489
+ }
490
+
491
+ return {
492
+ checker: "validate-pr-body-spec",
493
+ ok: errors.length === 0,
494
+ errors,
495
+ sections: sections.map((s) => s.name),
496
+ acItems,
497
+ dodItems,
498
+ closesIssues,
499
+ };
500
+ }
501
+
251
502
  /**
252
503
  * Map a draft-gate refinement check to the result surface consumed by
253
504
  * `evaluatePrGateCoordination`. The mapping keeps the contract
@@ -133,7 +133,7 @@ export const LIFECYCLE_NEXT_ACTIONS = Object.freeze({
133
133
  [LIFECYCLE_STATE.PRE_APPROVAL_GATE]:
134
134
  "Run pre-approval gate review; verify gate evidence, CI, and unresolved threads.",
135
135
  [LIFECYCLE_STATE.MERGE]:
136
- "Merge is authorized; run the final merge step and write the retrospective checkpoint.",
136
+ "Merge is authorized; run the final merge step. The retrospective is advisory and post-merge: it records flagged raw-calls for the conductor, never blocks a transition.",
137
137
  });
138
138
 
139
139
  // ---------------------------------------------------------------------------