@nanobpm/nano-workforce 0.200.4 → 0.200.5
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/CHANGELOG.md +6 -0
- package/SPEC.md +33 -0
- package/app/deliveryGraph.test.ts +1750 -0
- package/app/deliveryGraph.ts +1277 -1
- package/package.json +1 -1
- package/resources/prompts/feature.md +11 -0
- package/resources/prompts/plan.md +61 -4
- package/resources/prompts/scope-classify.md +35 -2
- package/scripts/scope-closing-keyword.test.ts +139 -0
package/app/deliveryGraph.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { isPlausibleBranchName } from "./baseBranch.ts";
|
|
|
23
23
|
import { isEnvKey } from "./contracts.ts";
|
|
24
24
|
import { isConvergeTarget } from "./convergeTargets.ts";
|
|
25
25
|
import { isRawConvergeMergeJobType, NODE_COMPLETION_POLICIES } from "./nodePolicy.ts";
|
|
26
|
+
import { parseIssue } from "./plan.ts";
|
|
26
27
|
import { BACKOFFS, hasEmbeddedCredential, hasEmbeddedUrl, hasSchemeRelativeAuthority, isBackoff, isUrlShaped, redactEmbeddedCredentialUrl, redactEmbeddedSchemeRelativeUrl, redactEmbeddedUrl, redactString } from "./readiness.ts";
|
|
27
28
|
import { isResolvableRepo } from "./repoEnvelope.ts";
|
|
28
29
|
|
|
@@ -108,7 +109,8 @@ export type DeliveryGraphErrorCode =
|
|
|
108
109
|
| "scheme-relative-url-in-job-type"
|
|
109
110
|
| "invalid-credential-env"
|
|
110
111
|
| "invalid-backoff"
|
|
111
|
-
| "unbound-pr"
|
|
112
|
+
| "unbound-pr"
|
|
113
|
+
| "partial-scope-close";
|
|
112
114
|
|
|
113
115
|
/** A single semantic validation failure. `path` is a JSON-path-qualified pointer at the offending
|
|
114
116
|
* input (`nodes[2].kind`, `edges[1].from`, `nodes[0].emits[1].name`), `message` is human-actionable,
|
|
@@ -267,6 +269,1261 @@ export const FACT_NAME_MAX_LENGTH = 128;
|
|
|
267
269
|
const NODE_ID_PATTERN = /^[A-Za-z_][A-Za-z0-9_.-]*$/;
|
|
268
270
|
const NODE_ID_MAX_LENGTH = 128;
|
|
269
271
|
|
|
272
|
+
/** Issue #858: a delivery-graph `agent` node's `prompt` is PLANNER/authored free text — no code path
|
|
273
|
+
* injects a `Closes #N` for it (unlike the single-issue `feature.ts` path, which owns its whole
|
|
274
|
+
* issue). The field case (delivery graph `5e36636255ab`, node `i12`) paired a brief scoped to ONE of
|
|
275
|
+
* an issue's three acceptance criteria with "…and open a PR that closes it", so the agent wrote
|
|
276
|
+
* `Closes #N` and the remainder was silently dropped. The planner/feature/scope-gate prompt contracts
|
|
277
|
+
* (resources/prompts/{plan,feature,scope-classify}.md) carry the prose rule; THIS is the deterministic
|
|
278
|
+
* compile/lint-time guard the issue asks for ("validate this at graph compile/lint time: an agent
|
|
279
|
+
* prompt that says 'close(s) #N' must cover all of #N's checkboxes, or carry an explicit partial-scope
|
|
280
|
+
* marker"). The validator is pure — it cannot read the GitHub issue to count its checkboxes — so the
|
|
281
|
+
* enforceable, self-contained form is: a prompt that closes an issue must ALSO carry an explicit
|
|
282
|
+
* full-scope acknowledgement marker (the exact phrase the planner is told to write when a slice
|
|
283
|
+
* legitimately closes, e.g. "full stated scope" / "every acceptance criterion"). A closing keyword
|
|
284
|
+
* with NO such marker is the defect class — a partial brief told to close — and is rejected.
|
|
285
|
+
*
|
|
286
|
+
* `ISSUE_REF_PATTERN` is the "the prompt references an issue SOMEWHERE" guard (so a bare prose "close
|
|
287
|
+
* the door" / "closes it" with no `#N` never matches). It is NOT a closing-language grammar — closing
|
|
288
|
+
* detection AND targeting are done by the SINGLE authoritative, negation-aware grammar
|
|
289
|
+
* `CLOSING_TARGET_PATTERN` (via `closingTargets`). There used to be a second `CLOSING_ACTION_PATTERN`
|
|
290
|
+
* pre-filter here that duplicated that whole active+passive grammar; it was removed (issue #858
|
|
291
|
+
* round-16 review) because the duplication was a DRIFT SURFACE that violated the one-canonical rule
|
|
292
|
+
* (AGENTS.md "derivation over duplication"): any syntax added only to `CLOSING_TARGET_PATTERN` was
|
|
293
|
+
* silently unreachable through the prefilter and failed OPEN, and the review history repeatedly forced
|
|
294
|
+
* the two regexes to be edited in lock-step. `closingTargets` already answers "is there an active,
|
|
295
|
+
* non-negated close?" (it returns no target when there is none), so `isPartialScopeClose` derives that
|
|
296
|
+
* answer from the one grammar instead of gating it behind a divergent copy. */
|
|
297
|
+
const ISSUE_REF_PATTERN =
|
|
298
|
+
/(?:#[0-9]+|[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+#[0-9]+|https?:\/\/[^\s)]*\/issues\/[0-9]+)/i;
|
|
299
|
+
|
|
300
|
+
/** A repo-qualified issue IDENTITY key (issue #858 round-4 review). The accepted issue syntax includes
|
|
301
|
+
* `owner/repo#N` and issue URLs, and this validator supports cross-repository graphs, so collapsing an
|
|
302
|
+
* issue to its bare number `N` would let an acknowledgement of `owner/alpha#12`'s scope licence closing
|
|
303
|
+
* a DIFFERENT issue `owner/beta#12` — both become `12`. The key preserves repository identity: an
|
|
304
|
+
* EXPLICIT `owner/repo` (from `owner/repo#N` or an issue URL) keys as `owner/repo#N` (lowercased); a
|
|
305
|
+
* BARE `#N` keys as `#N` (the implicit node/run repository). Two keys name the same issue only when
|
|
306
|
+
* equal, so `owner/alpha#12`, `owner/beta#12`, and bare `#12` are three distinct issues. This is
|
|
307
|
+
* fail-closed: a bare acknowledgement never credits an explicitly-qualified close of a different repo
|
|
308
|
+
* (and vice-versa), so a cross-repo number collision can no longer bypass the guard. */
|
|
309
|
+
function issueKey(repo: string | null | undefined, num: string | number): string {
|
|
310
|
+
const r = (repo ?? "").trim().toLowerCase();
|
|
311
|
+
return `${r}#${num}`;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Every issue referenced in `text`, as a repo-qualified identity key (see `issueKey`), in any accepted
|
|
315
|
+
* form (`#N`, `owner/repo#N`, or an issue URL `…/owner/repo/issues/N`). Used to (a) anchor a full-scope
|
|
316
|
+
* acknowledgement to the issue it names and (b) decide whether a prompt is single-issue (so a generic,
|
|
317
|
+
* un-numbered marker is unambiguous).
|
|
318
|
+
*
|
|
319
|
+
* The `owner/repo#N` and issue-URL shapes are NOT re-declared here as a local capture regex — that
|
|
320
|
+
* duplicate grammar is exactly what the single-grammar guard (`app/prShapeSingleGrammar.test.ts`,
|
|
321
|
+
* #856/#857) bans. Instead each candidate token is classified by the CANONICAL `parseIssue`
|
|
322
|
+
* (`app/prParse.ts`), the one source of the issue-shape grammar. `ISSUE_REF_TOKEN` only finds the
|
|
323
|
+
* candidate tokens (a bare `#N`, an `owner/repo#N`, or an `http(s)` URL); it carries NO digit-capture
|
|
324
|
+
* group, so it is not a parser. A token that merely LOOKS like a reference but is not the canonical
|
|
325
|
+
* shape (a non-`github.com` issue URL, a multi-slash `a/b/c#9`) is rejected by `parseIssue` and
|
|
326
|
+
* contributes nothing — fail-closed, matching the canonical grammar exactly. */
|
|
327
|
+
const ISSUE_REF_TOKEN = /#[0-9]+\b|[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+#[0-9]+\b|https?:\/\/[^\s)]+/gi;
|
|
328
|
+
function issueRefsIn(text: string): string[] {
|
|
329
|
+
const out: string[] = [];
|
|
330
|
+
for (const m of text.matchAll(ISSUE_REF_TOKEN)) {
|
|
331
|
+
const tok = m[0];
|
|
332
|
+
// Bare `#N` has no repo — key it as the implicit node/run repository (parseIssue rejects a
|
|
333
|
+
// repo-less ref, so handle it directly rather than through the canonical parser).
|
|
334
|
+
if (tok.startsWith("#")) {
|
|
335
|
+
out.push(issueKey(null, tok.slice(1)));
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
const parsed = parseIssue(tok);
|
|
339
|
+
if (parsed) out.push(issueKey(parsed.repo, parsed.number));
|
|
340
|
+
}
|
|
341
|
+
return out;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** The closing TARGETS of a prompt: which issue the closing verb actually acts on, as repo-qualified
|
|
345
|
+
* identity keys (see `issueKey`). A numbered target (`closes #N`, `fixes owner/repo#N`, `resolves
|
|
346
|
+
* <url>/.../issues/N`, or the `issue #N` / `GitHub issue #N` noun-phrase form) yields its key; a pronoun
|
|
347
|
+
* target (`closes it` / `close the issue`) can't name an issue, so it is reported via `pronoun` and the
|
|
348
|
+
* caller falls back to "the sole issue referenced". This is the SINGLE authoritative closing grammar —
|
|
349
|
+
* both "is there a close?" (detection) and "which issue?" (targeting) derive from it, with no separate
|
|
350
|
+
* pre-filter to drift from (issue #858 round-16 review removed the duplicate `CLOSING_ACTION_PATTERN`).
|
|
351
|
+
* It recognises the verb+object forms (`close/closes/closed`, `fix/fixes/fixed`,
|
|
352
|
+
* `resolve/resolves/resolved` applied to `#N`, `owner/repo#N`, an issue URL, the `issue #N` /
|
|
353
|
+
* `GitHub issue #N` noun phrase, or a pronoun), the optional colon (`Closes: #12`), and the
|
|
354
|
+
* quantifier/determiner on either side of `issues?` (`close both issues #12 and #13`); it
|
|
355
|
+
* preserves the `owner/repo` prefix (or URL repo) so a close of `owner/beta#12` is
|
|
356
|
+
* NOT satisfied by an acknowledgement anchored to `owner/alpha#12`.
|
|
357
|
+
*
|
|
358
|
+
* A DIRECTLY-NEGATED close is NOT a closing target (issue #858 round-7 review). A brief that explicitly
|
|
359
|
+
* forbids the close (`Do not close #12; use Part of #12.`, `never close #12`, `don't resolve #12`) is a
|
|
360
|
+
* SAFE partial-slice brief following the partial-slice contract — the OPPOSITE of the defect (a
|
|
361
|
+
* part-scope node told TO close) — so flagging it blocks a legitimate graph. Each match is dropped when
|
|
362
|
+
* the text immediately before its verb ends in a negator (`NEGATED_CLOSE_PREFIX`), so a negated close
|
|
363
|
+
* no longer contributes a target. The negator→verb gap admits only ADVERBS (`do not simply close`), not
|
|
364
|
+
* arbitrary words, so a meaning-flipping idiom (`do not forget to close #12` — "forget to" is a verb,
|
|
365
|
+
* not an adverb) stays an ACTIVE close and is still flagged; and negating ONE close never masks a
|
|
366
|
+
* DIFFERENT active close in the same prompt (`do not close #12, but close #34` still targets #34).
|
|
367
|
+
*
|
|
368
|
+
* The SECOND alternative is the ISSUE-FIRST / PASSIVE mirror of the verb-first grammar (issue #858
|
|
369
|
+
* round-10 review): `<issue-ref> [auxiliaries] closed|fixed|resolved` — `ensure issue #12 is closed by
|
|
370
|
+
* the PR`, `#12 will be closed by the PR`, `see #12 closed`, `mark #12 as resolved`, `the issue gets
|
|
371
|
+
* fixed`. It captures the SAME target shapes (groups 5/6 = repo/number for `#N`, 7/8 for a URL, a
|
|
372
|
+
* bare pronoun subject otherwise) so a passive close is attributed to its issue exactly like the
|
|
373
|
+
* active form, and captures the auxiliary window (group 19) so a negator INSIDE it (`#12 is NOT
|
|
374
|
+
* closed`) can drop the match — see `NEGATED_PASSIVE_WINDOW`. The passive subject carries the SAME
|
|
375
|
+
* coordination tail as the active arm (group 14, re-scanned in `closingTargets`), so a coordinated
|
|
376
|
+
* passive close (`issues #12 and #13 are closed`) attributes the close to EVERY subject, not just the
|
|
377
|
+
* first (issue #858 round-18 review). This passive arm lives ONLY here, in the
|
|
378
|
+
* one authoritative grammar — there is no duplicate pre-filter carrying a second copy of it.
|
|
379
|
+
*
|
|
380
|
+
* The issue-REFERENCE shape inside this grammar is NOT a local capture regex. The single-grammar guard
|
|
381
|
+
* (`app/prShapeSingleGrammar.test.ts`, #856/#857) bans any module but `app/prParse.ts`/`app/plan.ts`
|
|
382
|
+
* from carrying a `#(<digits>` or `/issues/(<digits>` capture group, so this pattern matches the
|
|
383
|
+
* reference with the CAPTURE-FREE {@link ISSUE_REF_SRC} fragment (no digit-capture group) and
|
|
384
|
+
* `closingTargets` then classifies the matched reference substring through the CANONICAL `parseIssue`
|
|
385
|
+
* — the one source of the issue-shape grammar. The grammar's structure (which target shapes exist, the
|
|
386
|
+
* coordination tails, the passive auxiliary window) stays here; only the reference *lexing* delegates
|
|
387
|
+
* to the canonical parser. */
|
|
388
|
+
const ISSUE_REF_SRC = "(?:[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+#[0-9]+|https?://[^\\s)]+|#[0-9]+)";
|
|
389
|
+
const CLOSING_TARGET_PATTERN = new RegExp(
|
|
390
|
+
String.raw`(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\b\s*(?::\s*){0,2}(?:(?:both|all|each|every|the)\s+)?(?:(?:github\s+)?issues?\s+)?(?:(?:both|all|each|every|the)\s+)?(${ISSUE_REF_SRC}|it\b|its\s+issue\b|the issue\b|that issue\b|this issue\b|them\b)((?:\s*(?:,|and\b|&|\+|along\s+with|as\s+well\s+as|plus)\s*(?:(?:(?:both|all|each|every|the)\s+)?(?:(?:github\s+)?issues?\s+)?(?:(?:both|all|each|every|the)\s+)?${ISSUE_REF_SRC})*)*)|(?:(?<![A-Za-z0-9_.-])(?:(?:github\s+)?issues?\s+)?(${ISSUE_REF_SRC})\b|\b(?:the|that|this|its)\s+issue\b|\bit\b|\bthem\b)((?:\s*(?:,|and\b|&|\+|along\s+with|as\s+well\s+as|plus)\s*(?:(?:(?:both|all|each|every|the)\s+)?(?:(?:github\s+)?issues?\s+)?(?:(?:both|all|each|every|the)\s+)?${ISSUE_REF_SRC})*)*)\s+((?:\w+\s+){0,4}(?:gets?\s+|get\s+)?(?:closed|fixed|resolved)\b)`,
|
|
391
|
+
"gi",
|
|
392
|
+
);
|
|
393
|
+
/** A negator DIRECTLY governing a closing verb, anchored (`$`) to the text ending right before the verb.
|
|
394
|
+
* Covers auxiliary+not (`do/does/did/will/would/shall/should/must/may/might not`), the common
|
|
395
|
+
* contractions, bare `not`/`never`/`cannot`, and `no need to`. Between the negator and the verb only
|
|
396
|
+
* ADVERBS and the infinitive marker `to` are admitted (`(?:\w+ly|ever|just|simply|only|then|also|now|
|
|
397
|
+
* yet|…|to)\s+){0,3}`) — NOT arbitrary words — so a verb between them (`do not forget to close` /
|
|
398
|
+
* `do not hesitate to close` — "forget"/"hesitate" is a verb, not an adverb or `to`) does not bridge
|
|
399
|
+
* and the close stays active, and an earlier unrelated negation blocked by punctuation/a verb (`do not
|
|
400
|
+
* introduce regressions; close #12`) never reaches the verb. Admitting `to` covers the `not to close`
|
|
401
|
+
* INFINITIVE (`Remember not to close #12.`, `Be sure not to close #12.`) — a brief that forbids the
|
|
402
|
+
* close that way is a safe partial-slice brief, not a partial-scope-close (issue #858 round-8
|
|
403
|
+
* adversarial review). */
|
|
404
|
+
const NEGATED_CLOSE_PREFIX =
|
|
405
|
+
/(?:\b(?:do|does|did|will|would|shall|should|must|may|might)\s+not|\b(?:don|doesn|didn|won|wouldn|shouldn|mustn|mightn|shan|can)['’]t|\bcannot|\bnever|\bnot|\bno\s+need\s+to)\s+(?:(?:\w+ly|ever|just|simply|only|then|also|now|yet|automatically|silently|blindly|actually|really|to|use|using|write|writing|include|including|add|adding)\s+){0,3}(?:(?:a|an|the)\s+)?$/i;
|
|
406
|
+
/** The CORRELATIVE additive negation `not <adverb> …` is ADDITIVE, not prohibitive, when paired with an
|
|
407
|
+
* additive continuation: `Do not just close #12; also add a release note` still INSTRUCTS the close
|
|
408
|
+
* (`not only X but/also Y` keeps X), so treating it as a negated close drops a real close and lets the
|
|
409
|
+
* partial brief validate (issue #858 round-8 review). This prefix matches a negator whose gap is
|
|
410
|
+
* governed by a correlative/manner adverb immediately before the verb; paired with a following additive
|
|
411
|
+
* continuation (`ADDITIVE_CONTINUATION`) it RE-ACTIVATES the close in `closingTargets`. The class spans
|
|
412
|
+
* the correlative `just`/`only` AND any `-ly` manner adverb (`merely`/`simply`/`basically`/`hardly`/…) —
|
|
413
|
+
* the additive signal is the `also`/`as well` CONTINUATION, not the specific adverb, so `Do not merely
|
|
414
|
+
* close #12; also …` is the same additive construction (issue #858 round-8 adversarial review). A manner
|
|
415
|
+
* adverb with NO additive continuation (`do not simply close it; leave the parent open`) genuinely
|
|
416
|
+
* forbids the close and stays a safe negated close — the continuation, not the adverb, is what
|
|
417
|
+
* re-activates. */
|
|
418
|
+
const CORRELATIVE_NEGATED_CLOSE_PREFIX =
|
|
419
|
+
/(?:\b(?:do|does|did|will|would|shall|should|must|may|might)\s+not|\b(?:don|doesn|didn|won|wouldn|shouldn|mustn|mightn|shan|can)['’]t|\bcannot|\bnever|\bnot|\bno\s+need\s+to)\s+(?:(?:\w+ly|ever|then|also|now|yet|automatically|silently|blindly|actually|really|to)\s+){0,2}(?:just|only|\w+ly)\s+(?:(?:\w+ly|ever|then|now|yet|automatically|silently|blindly|actually|really|to)\s+){0,2}$/i;
|
|
420
|
+
/** The additive continuation that reinstates a `not <adverb>` close: an additive marker — `also` or
|
|
421
|
+
* `as well` — within the SAME sentence after the close (bounded at `.`/newline so an unrelated later
|
|
422
|
+
* sentence never re-activates the close). The signal is the ADDITIVE word, never a bare `but`: a bare
|
|
423
|
+
* contrastive `but` (`Do not just close it; but leave the parent open`) introduces a CONTRAST, not an
|
|
424
|
+
* added action, so it is a SAFE negated close — admitting it was a false-positive REGRESSION vs. round-7
|
|
425
|
+
* (issue #858 round-8 adversarial review). The correlative `not only X but ALSO Y` still carries `also`,
|
|
426
|
+
* so it is caught here without the bare-`but` false positive. `as well` is matched as a phrase (it never
|
|
427
|
+
* leads a clause, so a word-boundary false positive is not a concern). A bare `too` is deliberately NOT
|
|
428
|
+
* an additive trigger: it is also the INTENSIFIER (`too risky`/`too early`), so matching it over-fired on
|
|
429
|
+
* a safe negated close followed by a `too <adj>` constraint — a fail-closed tradeoff that keeps `too` a
|
|
430
|
+
* safe negation. Tested on the text immediately AFTER the matched close. */
|
|
431
|
+
const ADDITIVE_CONTINUATION = /^[^.\n]*?\b(?:also|as\s+well)\b/i;
|
|
432
|
+
/** A negator inside the AUXILIARY WINDOW of an issue-first/passive close (`issue #12 is NOT closed by
|
|
433
|
+
* the PR`, `#12 will NEVER be fixed by the PR`, `the issue is not getting resolved`) — the passive
|
|
434
|
+
* mirror of `NEGATED_CLOSE_PREFIX` (issue #858 round-10 review). A brief that explicitly forbids the
|
|
435
|
+
* passive close (`Implement criterion 1 of #12; issue #12 is NOT closed by this PR`) is a SAFE
|
|
436
|
+
* partial-slice brief, exactly like its active-voice sibling (`do not close #12`), so the match is
|
|
437
|
+
* dropped. The negator is searched only inside the match's own captured auxiliary window (group 19 of
|
|
438
|
+
* `CLOSING_TARGET_PATTERN`) — never across the whole prompt — so an unrelated earlier negation
|
|
439
|
+
* (`do not introduce regressions; issue #12 is closed by the PR`) cannot mask an ACTIVE passive close,
|
|
440
|
+
* and a `not` AFTER the participle (`#12 is closed, not merely referenced`) is not read as negating
|
|
441
|
+
* the close. The window is at most four words plus an optional `get(s)`, so a plain substring test
|
|
442
|
+
* suffices — no anchoring needed. */
|
|
443
|
+
const NEGATED_PASSIVE_WINDOW = /\b(?:not|never|cannot)\b|\b\w+n['’]t\b/i;
|
|
444
|
+
/** Classify a matched issue-REFERENCE substring (from {@link ISSUE_REF_SRC}) into its repo-qualified
|
|
445
|
+
* identity key via the CANONICAL `parseIssue`, or null when the substring is not the canonical shape
|
|
446
|
+
* (a non-`github.com` issue URL, a multi-slash `a/b/c#9`). A bare `#N` (no repo) keys as the implicit
|
|
447
|
+
* node/run repository — `parseIssue` rejects a repo-less ref, so it is keyed directly. Returning null
|
|
448
|
+
* (rather than a key) for a non-canonical reference keeps the close UN-attributed, so the validator
|
|
449
|
+
* fails closed exactly as the canonical grammar does. */
|
|
450
|
+
function issueRefKey(ref: string | undefined): string | null {
|
|
451
|
+
if (!ref) return null;
|
|
452
|
+
if (ref.startsWith("#")) return issueKey(null, ref.slice(1));
|
|
453
|
+
// A URL token is lexed as a maximal `[^\s)]+` run, so it can carry a trailing possessive (`'s`) or
|
|
454
|
+
// prose punctuation (`;`, `,`, `.`, `:`, `!`, `?`) that is not part of the reference (`…/issues/12's
|
|
455
|
+
// full scope`, `…/issues/12; note …`). The canonical parser is anchored, so strip the trailing
|
|
456
|
+
// possessive/punctuation before classifying — a bare `#N` token ends in a digit and never needs this.
|
|
457
|
+
const cleaned = ref.replace(/(?:['’]s|s['’]|['’])?[;.,:!?]*$/, "");
|
|
458
|
+
const parsed = parseIssue(cleaned);
|
|
459
|
+
return parsed ? issueKey(parsed.repo, parsed.number) : null;
|
|
460
|
+
}
|
|
461
|
+
function closingTargets(prompt: string): { numbered: string[]; pronoun: boolean } {
|
|
462
|
+
const numbered: string[] = [];
|
|
463
|
+
let pronoun = false;
|
|
464
|
+
for (const m of prompt.matchAll(CLOSING_TARGET_PATTERN)) {
|
|
465
|
+
// A directly-negated close (`do not close #12`) is not a close TARGET — skip it. The negation never
|
|
466
|
+
// masks a sibling ACTIVE close: each match is judged on the text before its OWN verb. EXCEPTION: an
|
|
467
|
+
// additive `not just/only … ; also/but …` correlative still INSTRUCTS the close, so it stays a target
|
|
468
|
+
// (issue #858 round-8 review).
|
|
469
|
+
const before = prompt.slice(0, m.index);
|
|
470
|
+
if (NEGATED_CLOSE_PREFIX.test(before)) {
|
|
471
|
+
const additive =
|
|
472
|
+
CORRELATIVE_NEGATED_CLOSE_PREFIX.test(before) &&
|
|
473
|
+
ADDITIVE_CONTINUATION.test(prompt.slice(m.index + m[0].length));
|
|
474
|
+
if (!additive) continue;
|
|
475
|
+
}
|
|
476
|
+
// The issue-first/passive arm carries its negation INSIDE the match's auxiliary window (`#12 is
|
|
477
|
+
// NOT closed`), which the before-verb prefix check above cannot see — the negator sits AFTER the
|
|
478
|
+
// arm's issue-ref start, so the text ending at `m.index` does not reach it. Drop a passive close
|
|
479
|
+
// whose own window is negated (issue #858 round-10 review). The window is group 5.
|
|
480
|
+
if (m[5] !== undefined && NEGATED_PASSIVE_WINDOW.test(m[5])) continue;
|
|
481
|
+
// Group 1 is the ACTIVE arm's issue reference (or a pronoun); group 3 is the PASSIVE arm's subject
|
|
482
|
+
// reference. Classify each through the canonical parser; a pronoun (`it`/`them`/`the issue`) is not
|
|
483
|
+
// a reference, so it sets `pronoun` instead.
|
|
484
|
+
const activeRef = m[1];
|
|
485
|
+
const passiveRef = m[3];
|
|
486
|
+
const activeKey = issueRefKey(activeRef);
|
|
487
|
+
const passiveKey = issueRefKey(passiveRef);
|
|
488
|
+
if (activeKey) numbered.push(activeKey);
|
|
489
|
+
else if (passiveKey) numbered.push(passiveKey);
|
|
490
|
+
else pronoun = true;
|
|
491
|
+
// A COORDINATED close names every target (`close #12 and #13`, `close #12, #13, and #14`). The
|
|
492
|
+
// active arm captures only the FIRST; group 2 is the whole coordinated tail, re-scanned for every
|
|
493
|
+
// extra numbered target so each closed issue needs its own acknowledgement (issue #858 round-11
|
|
494
|
+
// review — `close #12 and #13` previously validated with only #12 acknowledged, a fail-open bypass).
|
|
495
|
+
if (m[2] !== undefined && m[2] !== "") {
|
|
496
|
+
for (const key of issueRefsIn(m[2])) numbered.push(key);
|
|
497
|
+
}
|
|
498
|
+
// The issue-first/passive arm's subject is coordinated the same way (`issues #12 and #13 are
|
|
499
|
+
// closed`); group 4 is ITS whole coordinated tail, re-scanned identically so a passive close of
|
|
500
|
+
// several issues checks every one (issue #858 round-18 review — `issues #12 and #13 are closed`
|
|
501
|
+
// previously checked only the first subject, the same fail-open bypass one arm over).
|
|
502
|
+
if (m[4] !== undefined && m[4] !== "") {
|
|
503
|
+
for (const key of issueRefsIn(m[4])) numbered.push(key);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return { numbered, pronoun };
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** A whole-scope phrase ATTRIBUTED to someone other than this brief ("…is handled BY siblings",
|
|
510
|
+
* "delivered BY the other slices", "owned BY another slice") does NOT acknowledge that THIS brief
|
|
511
|
+
* owns the scope — it says the opposite. Such an occurrence is disqualified so it cannot licence a
|
|
512
|
+
* close (issue #858 round-3: the full-scope marker must assert this node's ownership of the closing
|
|
513
|
+
* target, not merely mention the scope). Matches a completion verb immediately followed by `by` SOME
|
|
514
|
+
* OTHER agent, or a `by <sibling/other/peer/the rest>` agent phrase. Attribution to the CURRENT slice
|
|
515
|
+
* is an AFFIRMATIVE ownership assertion, not a disclaimer, so the `<verb> by` alternative excludes a
|
|
516
|
+
* self-reference agent (`by this slice` / `by the current slice` / `by me` / `by us` / `by me here`)
|
|
517
|
+
* via a negative lookahead — only attribution to a non-self agent disqualifies (issue #858 round-5
|
|
518
|
+
* review: "allow current-slice ownership in passive attribution"). The sibling/peer/other alternative
|
|
519
|
+
* allows an optional POSSESSIVE (`our`/`their`/`the`) before the noun — `delivered by OUR siblings` is
|
|
520
|
+
* still attribution to OTHERS even though `our` is a self word (the round-5 self-exclusion lookahead
|
|
521
|
+
* added `our`/`us`/`my`, which let `by our siblings` slip through both alternatives; issue #858
|
|
522
|
+
* round-5 adversarial review). A possessive before a SELF noun (`by our team` / `by our slice`) is
|
|
523
|
+
* unaffected — those nouns are not in the others list, so they still fail the lookahead-excluded self
|
|
524
|
+
* branch and do NOT disqualify.
|
|
525
|
+
*
|
|
526
|
+
* The `<verb> by` branch ALSO excludes an implementation METHOD — a `by <gerund>` means-clause that
|
|
527
|
+
* says HOW this slice delivers the scope (`implemented by updating the parser`, `satisfied by adding
|
|
528
|
+
* the migration`, `delivered by carefully refactoring`), NOT attribution to another owner (issue #858
|
|
529
|
+
* round-7 review). A negative lookahead right after `by\s+` skips a (optionally adverb-prefixed)
|
|
530
|
+
* gerund, so an "implemented by doing X" acknowledgement stays valid. The gerund is excluded only
|
|
531
|
+
* when it GOVERNS an object — i.e. it is followed by whitespace plus a continuation that is not a
|
|
532
|
+
* coordinator (`and`/`or`/`then`) or a relative pronoun (`that`/`which`/`who`): `by updating the
|
|
533
|
+
* parser` / `by doing it` / `by filing tickets` are means-clauses. A BARE terminal `-ing` word (end
|
|
534
|
+
* of the assertion, or punctuation next) is an ACTOR noun, not a method — `owned by engineering`,
|
|
535
|
+
* `handled by marketing`, `covered by staffing`, `provided by consulting` all still disqualify
|
|
536
|
+
* (issue #858 round-8 adversarial review: the round-7 lookahead excluded ANY bare `-ing` word after
|
|
537
|
+
* `by`, a fail-OPEN regression vs. round-6 that let an `-ing`-named actor slip past attribution).
|
|
538
|
+
* The coordinator/relative-pronoun guard keeps an `-ing` noun CONJUNCT or noun+relative-clause an
|
|
539
|
+
* actor (`by engineering and product`, `by engineering that reports to product`). A DETERMINER before
|
|
540
|
+
* an `-ing` word (`by the training team`) makes it an actor noun phrase, not a bare gerund, so it is
|
|
541
|
+
* NOT excluded and still disqualifies — attribution to a real actor is preserved. One accepted
|
|
542
|
+
* tradeoff, fail-CLOSED: an intransitive or adverb-led gerund with no object (`by pairing`, `by
|
|
543
|
+
* carefully refactoring` at assertion end) reads as an actor noun and still disqualifies — a rare
|
|
544
|
+
* phrasing that errs toward flagging, never toward letting an attribution through.
|
|
545
|
+
*
|
|
546
|
+
* Tested against the marker's DELIVERY ASSERTION, not its whole comma-bounded clause (see
|
|
547
|
+
* `deliveryAssertionAround`): an attribution governing an UNRELATED constraint coordinated onto the
|
|
548
|
+
* clause by `and` (`Deliver the full scope of #12 AND the regression suite is handled by another team;
|
|
549
|
+
* close #12.`) attributes that other constraint, not the marker's scope, so it must NOT disqualify the
|
|
550
|
+
* close — the SAME false-positive class round-6 scoped the two negation disqualifiers for (issue #858
|
|
551
|
+
* round-6 adversarial review). An attribution in the marker's OWN segment (`the full scope of #12 is
|
|
552
|
+
* handled by siblings`) has no coordinator between it and the marker, so it stays in-segment and still
|
|
553
|
+
* disqualifies. */
|
|
554
|
+
const SCOPE_ATTRIBUTED_TO_OTHERS =
|
|
555
|
+
/(?:handled|delivered|covered|owned|done|provided|implemented|built|completed|satisfied|addressed|met)\s+by\s+(?!(?:the\s+)?(?:this|current|present|me|us|our|my|myself|ourselves|here)\b)(?!(?:\w+ly\s+)?\w+ing\s+(?:[^\w\s]|(?!and\b|or\b|then\b|that\b|which\b|who\b)\w))|\bby\s+(?:(?:the|our|their|its|his|her)\s+)?(?:siblings?|others?|another|peers?|other\s+slices?|sibling\s+slices?|the\s+rest|the\s+others?)\b/i;
|
|
556
|
+
|
|
557
|
+
/** ACTIVE-VOICE attribution to others: a sibling/other-slice SUBJECT performing a completion verb on the
|
|
558
|
+
* scope (`Siblings deliver the full scope of #12`, `the other slices own every criterion`, `another
|
|
559
|
+
* slice covers the whole issue`) credits OTHERS with the delivery exactly as the passive `delivered by
|
|
560
|
+
* siblings` does, so it must disqualify the marker too (issue #858 round-8 review: the passive-only
|
|
561
|
+
* `SCOPE_ATTRIBUTED_TO_OTHERS` let an active-voice sibling-ownership sentence restore the attribution
|
|
562
|
+
* bypass, contradicting the per-node ownership invariant). Matches an OTHERS-noun subject (siblings /
|
|
563
|
+
* peers / another slice / other slices / the rest / the others, optionally possessive) followed within a
|
|
564
|
+
* couple of words by a completion verb — the active voice of `SCOPE_ATTRIBUTED_TO_OTHERS`'s verb list.
|
|
565
|
+
* The subject allowlist is OTHERS only (never `this`/`current` slice, `I`, `we`), so active-voice SELF
|
|
566
|
+
* ownership (`this slice delivers the full scope`) stays an affirmative assertion. A `our`-POSSESSIVE
|
|
567
|
+
* self-reference (`our team delivers the full scope`) is likewise SELF-ownership — the passive equivalent
|
|
568
|
+
* `delivered by our team` is already treated as valid self-ownership (the `SCOPE_ATTRIBUTED_TO_OTHERS`
|
|
569
|
+
* self-exclusion) — so the possessive before a `team` noun excludes `our` specifically while staying
|
|
570
|
+
* OPTIONAL: a BARE `team`/`teams` (`Team delivers the full scope`) is still OTHERS-attribution exactly
|
|
571
|
+
* as the round-entry code flagged it — requiring a determiner there was a fail-open regression (issue
|
|
572
|
+
* #858 round-9 adversarial review). Tested against the
|
|
573
|
+
* marker's DELIVERY ASSERTION (like the passive check), so a sibling clause coordinated onto an
|
|
574
|
+
* UNRELATED constraint by `and`, or sitting in a different comma/`;`-bounded clause, does not over-fire.
|
|
575
|
+
*
|
|
576
|
+
* The subject-to-verb window spans up to four words AND tolerates a coordinator (`and`/`or`/`then`)
|
|
577
|
+
* inside it: the compound-predicate subject retention (see `deliveryAssertionAround`) deliberately keeps
|
|
578
|
+
* the subject for `Siblings plan AND deliver the full scope` — including its ADVERB-LED form
|
|
579
|
+
* (`COMPOUND_PREDICATE_LEAD` admits `(?:\w+ly\s+)?`, e.g. `Siblings plan and CAREFULLY deliver …`) — so
|
|
580
|
+
* this check must reach the verb across the coordinator gap, or the very attribution bypass the
|
|
581
|
+
* retention exists to close re-opens the moment one adverb is inserted (issue #858 round-9 adversarial
|
|
582
|
+
* review). Widening the window cannot over-fire onto a SELF assertion (`this slice plans and carefully
|
|
583
|
+
* delivers …`): the subject allowlist is OTHERS-only, so a self-subject never matches regardless of how
|
|
584
|
+
* many words precede the verb. */
|
|
585
|
+
const SCOPE_ACTIVE_VOICE_OTHERS =
|
|
586
|
+
/\b(?:(?:(?:the|their)\s+)?(?:siblings?|other\s+slices?|sibling\s+slices?|peers?|another\s+slice|the\s+rest|the\s+others?|others|upstream\s+slice|upstream\s+slices?)|(?:(?:(?!our\b)(?:the|their|another|other)\s+)?(?<!\bour )teams?))\s+(?:(?:(?:\w+ly|and|or|then|\w+)\s+)){0,4}?(?:handle|deliver|cover|own|provide|implement|build|complete|satisfy|address|meet|do|finish|ship)(?:s|es|ed|ing)?\b/i;
|
|
587
|
+
|
|
588
|
+
/** A whole-scope phrase whose CLAUSE explicitly NEGATES or DISCLAIMS it ("this slice does NOT deliver
|
|
589
|
+
* the full scope of #12", "we won't cover every acceptance criterion", "the full scope of #12 is NOT
|
|
590
|
+
* delivered here") does NOT acknowledge that this brief owns the scope — it asserts the opposite, yet
|
|
591
|
+
* the bare substring `full scope` is still present (issue #858 round-4 review). Such an occurrence is
|
|
592
|
+
* disqualified so a negated clause cannot licence a close and re-open the partial-close bypass.
|
|
593
|
+
*
|
|
594
|
+
* Negation comes in TWO grammatical shapes, handled separately so an UNRELATED trailing constraint is
|
|
595
|
+
* not mistaken for scope negation (issue #858 round-5 review — `Deliver the full scope of #12 without
|
|
596
|
+
* regressions; close #12.` must validate):
|
|
597
|
+
* - `SCOPE_NEGATED_CORE` — core negators (`not`/`never`/`cannot`/`n't`) and the delivery-failure
|
|
598
|
+
* idioms (`fails to`/`unable to`) that negate the assertion when they sit in the marker's OWN
|
|
599
|
+
* `and`-segment (`does not deliver the full scope`, `…full scope… is NOT delivered`). Tested against
|
|
600
|
+
* the marker's DELIVERY ASSERTION, not its whole comma-bounded clause (see
|
|
601
|
+
* `deliveryAssertionAround`): a core negator governing an UNRELATED constraint coordinated onto the
|
|
602
|
+
* clause by `and` (`Deliver the full scope of #12 AND do NOT introduce regressions; close #12.`)
|
|
603
|
+
* negates that other constraint, not the marker, so it must NOT disqualify the close (issue #858
|
|
604
|
+
* round-6 review). The assertion is the clause narrowed to the `and`-bounded segment the marker sits
|
|
605
|
+
* in, so a negator in a sibling coordinated segment is excluded while one in the marker's own segment
|
|
606
|
+
* (`does not deliver the full scope`) still disqualifies. The guarantee is therefore NARROWED from
|
|
607
|
+
* "wherever they sit": a negator split into a SIBLING `and`-segment no longer disqualifies via this
|
|
608
|
+
* pattern — unless it REFERENCES DELIVERY, which `SCOPE_NEGATED_AFTER_MARKER_DELIVERY` re-catches
|
|
609
|
+
* against the whole clause (issue #858 round-6 adversarial review).
|
|
610
|
+
* - `SCOPE_NEGATED_PREFIX` — exception/redirection PREFIXES (`without`, `other than`, `rather than`,
|
|
611
|
+
* `instead of`, `apart from`, `all but`, `excluding`, …) that negate only the phrase they GOVERN,
|
|
612
|
+
* i.e. the one that FOLLOWS them. They disqualify the marker only when they sit BEFORE it in the
|
|
613
|
+
* clause; a TRAILING occurrence governs some other phrase (`…full scope… WITHOUT regressions`,
|
|
614
|
+
* `…full scope… RATHER THAN a piecemeal split`) and is an affirmative closer.
|
|
615
|
+
*
|
|
616
|
+
* Conservative/fail-closed: the planner contract (plan.md) directs a genuine full-scope closer to
|
|
617
|
+
* carry a plain AFFIRMATIVE acknowledgement, so negating the marker's assertion is a disclaimer, not an
|
|
618
|
+
* assertion. The exception idiom covers `but` only in its narrow "except" phrases (`all but` /
|
|
619
|
+
* `everything but` / `anything but` / `nothing but`) — a BARE `but` is left out deliberately: it is a
|
|
620
|
+
* common affirmative conjunction ("the full scope of #12, but split across two commits"), so matching
|
|
621
|
+
* it would over-fire on legitimate closers. The assertion-narrowing coordinator is `and`/`plus` ONLY
|
|
622
|
+
* (NOT `but`/`or`): `but` is the exception idiom's own keyword (`but without covering X` must stay a
|
|
623
|
+
* disclaimer of the marker, so its segment must still include the trailing negation), and `and` is the
|
|
624
|
+
* unambiguous "independent additional constraint" conjunction the round-6 false positive turned on. */
|
|
625
|
+
const SCOPE_NEGATED_CORE = /\b(?:not|never|cannot|fail(?:s|ing|ed)?\s+to|unable\s+to)\b|n['’]t\b/i;
|
|
626
|
+
const SCOPE_NEGATED_PREFIX =
|
|
627
|
+
/\b(?:without|exclud(?:e|es|ing|ed)|omit(?:s|ting|ted)?|aside\s+from|apart\s+from|other\s+than|rather\s+than|instead\s+of|short\s+of|all\s+but|everything\s+but|anything\s+but|nothing\s+but)\b/i;
|
|
628
|
+
/** Global-flag twin of `SCOPE_NEGATED_PREFIX`, derived from its `source` so the two never drift. Used by
|
|
629
|
+
* `earliestNegatorEnd` to find, in ONE left-to-right scan of a clause, the earliest offset at which a
|
|
630
|
+
* prefix negator completes (so each marker can be tested against its OWN before-marker prefix without
|
|
631
|
+
* re-running the regex per marker — issue #858 round-14 review). */
|
|
632
|
+
const SCOPE_NEGATED_PREFIX_G = new RegExp(SCOPE_NEGATED_PREFIX.source, "gi");
|
|
633
|
+
|
|
634
|
+
/** A TRAILING `without <delivery gerund>` ("Deliver the full scope of #12 WITHOUT COVERING the edge
|
|
635
|
+
* cases") still disclaims completeness — it is a negation, not the benign `without <noun>` constraint
|
|
636
|
+
* ("without regressions") the before-marker-only split was carved out for. Restricting
|
|
637
|
+
* `SCOPE_NEGATED_PREFIX` to the before-marker text (so a trailing "without regressions" stays
|
|
638
|
+
* affirmative) was a fail-open regression for this trailing delivery-negating shape (issue #858
|
|
639
|
+
* round-5 adversarial review): round-4's whole-clause check caught it, the split let it through. This
|
|
640
|
+
* pattern distinguishes the two by what `without` GOVERNS: a delivery gerund (covering / delivering /
|
|
641
|
+
* implementing / finishing / …) means part of the scope is left undelivered, so the marker is
|
|
642
|
+
* disqualified; a plain noun (regressions / tests / breaking changes) is an unrelated trailing
|
|
643
|
+
* constraint and stays affirmative. Tested against the marker's DELIVERY ASSERTION (the gerund sits
|
|
644
|
+
* AFTER the marker; see `deliveryAssertionAround`) — like `SCOPE_NEGATED_CORE`, a `without <gerund>`
|
|
645
|
+
* coordinated onto the clause by `and` (`Deliver the full scope of #12 AND refactor without breaking
|
|
646
|
+
* the build; close #12.`) governs that other constraint, not the marker, and must NOT disqualify
|
|
647
|
+
* (issue #858 round-6 review). A `but without covering X` stays IN the marker's assertion (`but` is not
|
|
648
|
+
* an assertion-splitting coordinator), so that trailing disclaimer still disqualifies. Two guards keep
|
|
649
|
+
* it from over-firing on a benign noun that merely LOOKS like a gerund:
|
|
650
|
+
* - an article/determiner between `without` and the word ("without A covering letter", "without THE
|
|
651
|
+
* building blocks") makes the word a NOUN, so the lookbehinds exclude it; and
|
|
652
|
+
* - `meeting` is deliberately left OUT of the gerund list — "without meeting notes" (a benign noun)
|
|
653
|
+
* is more common in a brief than "without meeting every criterion", and the core-negator /
|
|
654
|
+
* scope-classify layers backstop that phrasing.
|
|
655
|
+
* The gerund list is the delivery vocabulary; a `without <noun>` never matches it. */
|
|
656
|
+
const SCOPE_NEGATED_WITHOUT_DELIVERY =
|
|
657
|
+
/\bwithout\s+(?:\w+\s+){0,2}(?<!the\s)(?<!a\s)(?<!an\s)(?<!any\s)(?<!its\s)(?<!their\s)(?<!our\s)(?<!my\s)(?<!your\s)(?<!his\s)(?<!her\s)(?:covering|delivering|implementing|finishing|completing|building|satisfying|addressing|providing|handling|doing|shipping|including)\b/i;
|
|
658
|
+
|
|
659
|
+
/** A core negator (`not`/`never`/`cannot`/`n't`/`fails to`/`unable to`) that sits AFTER the marker and
|
|
660
|
+
* REFERENCES DELIVERY still disclaims the marker's scope — `Deliver the full scope of #12 AND it is not
|
|
661
|
+
* fully delivered; close #12.` asserts the scope is NOT delivered, yet the `and`-coordinator splits that
|
|
662
|
+
* trailing disclaimer into a sibling segment the assertion-scoped `SCOPE_NEGATED_CORE` never sees, so
|
|
663
|
+
* round-6's narrowing let it VALIDATE (a fail-open regression vs. the pre-round whole-clause test; issue
|
|
664
|
+
* #858 round-6 adversarial review). Tested against the marker's whole comma-bounded CLAUSE (not the
|
|
665
|
+
* `and`-segment): the negator may sit in a trailing coordinated segment, and the DELIVERY reference is
|
|
666
|
+
* what ties it back to the marker. The delivery reference is EITHER a delivery noun/verb
|
|
667
|
+
* (`delivered`/`cover`/`implement`/`scope`/`criterion`…) OR the anaphoric `it`/`that` referring back to
|
|
668
|
+
* the just-named scope. An after-marker negation with NO delivery reference (`…AND do not introduce
|
|
669
|
+
* regressions`, `…AND never break the build`) governs an UNRELATED constraint and stays affirmative —
|
|
670
|
+
* this pattern does NOT match it, so the round-6 false-positive fix is preserved. The delivery
|
|
671
|
+
* vocabulary mirrors the attribution/gerund lists (deliver/cover/implement/complete/scope/criterion/
|
|
672
|
+
* checkbox/…) plus the anaphoric `it`/`that`. */
|
|
673
|
+
const SCOPE_NEGATED_AFTER_MARKER_DELIVERY =
|
|
674
|
+
/(?:\b(?:not|never|cannot|fail(?:s|ing|ed)?\s+to|unable\s+to)\b|\b\w+n['’]t\b)(?:\s+[\w'’-]+){0,4}?\s+(?:it|that|deliver(?:y|s|ed|ing)?|cover(?:s|ed|ing)?|implement(?:s|ed|ing)?|complet(?:e|es|ed|ing)|finish(?:es|ed|ing)?|satisf(?:y|ies|ied|ying)|acceptance\s+criteri\w+|checkbox\w*|scope|criteri\w+|done)\b/i;
|
|
675
|
+
|
|
676
|
+
/** The explicit full-scope acknowledgement markers that licence a closing keyword. These are the
|
|
677
|
+
* phrases the planner contract (resources/prompts/plan.md) directs a full-scope slice to carry, so a
|
|
678
|
+
* legitimately-closing brief already contains one and is NOT rejected. Matching is case-insensitive
|
|
679
|
+
* substring (not a bareword regex) so inflections ("full stated scope", "the full scope", "every
|
|
680
|
+
* acceptance criterion", "all acceptance criteria", "owns the whole issue") all count.
|
|
681
|
+
*
|
|
682
|
+
* Every marker is ISSUE-ANCHORED — it names the whole issue/scope as the thing delivered ("the whole
|
|
683
|
+
* issue", "all of #N", "the entire issue", "every acceptance criterion"). A BARE adverb of
|
|
684
|
+
* completeness (`fully` / `completely` / `end-to-end` / `in full` / `in its entirety`) is
|
|
685
|
+
* deliberately NOT a marker: it can modify a PARTIAL deliverable ("implement one criterion of #12
|
|
686
|
+
* fully, then close #12"), so accepting it would silently disable the guard for exactly the partial
|
|
687
|
+
* brief it exists to catch. The contract (plan.md) tells the planner to anchor the acknowledgement
|
|
688
|
+
* to the issue's whole scope, so a legitimate closer always has an anchored form available.
|
|
689
|
+
*
|
|
690
|
+
* These phrases are whole-scope LANGUAGE only; `isPartialScopeClose` decides, per closing target,
|
|
691
|
+
* whether a phrase occurrence is actually TIED to the issue being closed (same clause / sole issue)
|
|
692
|
+
* and not attributed to others — a phrase alone, anywhere in the prompt, is NOT sufficient. */
|
|
693
|
+
const FULL_SCOPE_MARKERS: readonly string[] = [
|
|
694
|
+
"full stated scope",
|
|
695
|
+
"full scope",
|
|
696
|
+
"every acceptance criterion",
|
|
697
|
+
"all acceptance criteria",
|
|
698
|
+
"every checkbox",
|
|
699
|
+
"all checkboxes",
|
|
700
|
+
"owns the whole",
|
|
701
|
+
"own the whole",
|
|
702
|
+
"complete stated scope",
|
|
703
|
+
"entire scope",
|
|
704
|
+
// Issue-anchored whole-scope paraphrases — the natural phrasings a planner uses when it genuinely
|
|
705
|
+
// scopes a slice to the whole issue (the under-inclusive-marker false positive this widens for).
|
|
706
|
+
"the whole issue",
|
|
707
|
+
"whole issue",
|
|
708
|
+
"the entire issue",
|
|
709
|
+
"entire issue",
|
|
710
|
+
"the complete issue",
|
|
711
|
+
"complete issue",
|
|
712
|
+
"all of #",
|
|
713
|
+
"all of the issue",
|
|
714
|
+
"the whole of #",
|
|
715
|
+
"the whole of the issue",
|
|
716
|
+
];
|
|
717
|
+
|
|
718
|
+
/** True when `prompt` pairs a GitHub closing action with NO full-scope acknowledgement TIED to the
|
|
719
|
+
* issue it closes — the partial-scope-close defect class (issue #858). Requires BOTH a closing action
|
|
720
|
+
* and an issue reference (so a bare prose "close the door" or "Part of #12" never matches).
|
|
721
|
+
*
|
|
722
|
+
* The acknowledgement is TARGET-ASSOCIATED, not a global substring (issue #858 round-3): a whole-scope
|
|
723
|
+
* phrase is credited to issue `N` only when `#N` is ANCHORED to that phrase (immediately adjacent —
|
|
724
|
+
* "all of #N", "the whole issue #N", "full scope of #N", "#N's full scope"), or — when the whole
|
|
725
|
+
* prompt references exactly one issue — to that sole issue (unambiguous). A marker anchored to a
|
|
726
|
+
* DIFFERENT issue than the one closed (acknowledge #11's scope, close #12), or one ATTRIBUTED to
|
|
727
|
+
* others ("the full scope of #12 is handled by siblings; … close #12"), does NOT licence the close.
|
|
728
|
+
* Every numbered closing target must be acknowledged; a pronoun close ("close it") requires every
|
|
729
|
+
* referenced issue to be acknowledged (we can't tell which "it" means, so fail closed).
|
|
730
|
+
*
|
|
731
|
+
* This is a lexical lint that cannot read the issue body, so it is the first of a two-layer guard:
|
|
732
|
+
* the scope-classify gate (resources/prompts/scope-classify.md) reads each closed issue's checkboxes
|
|
733
|
+
* at PR time and catches an under-delivery that is semantically — not lexically — a partial close. */
|
|
734
|
+
const CLAUSE_DELIMITERS = new Set([".", ";", ":", ",", "\n", "—"]);
|
|
735
|
+
|
|
736
|
+
/** An `http(s)://…` URL span. The body runs to whitespace or `)`; trailing clause-delimiter punctuation
|
|
737
|
+
* (`.`/`,`/`;`/`:`/`!`/`?`) is trimmed because it is sentence punctuation, not URL structure (a URL
|
|
738
|
+
* written at the end of a clause must not swallow that clause's boundary). Delimiters INSIDE the trimmed
|
|
739
|
+
* span (the `.` in `github.com`, the `:` in `https:`) are part of the URL, not clause boundaries. */
|
|
740
|
+
const URL_SPAN = /https?:\/\/[^\s)]+/gi;
|
|
741
|
+
const URL_TRAILING_PUNCT = /[.;:,!?]+$/;
|
|
742
|
+
|
|
743
|
+
/** The `[start,end)` spans of every URL in `prompt`, with trailing sentence punctuation excluded. */
|
|
744
|
+
function urlSpans(prompt: string): Array<[number, number]> {
|
|
745
|
+
const out: Array<[number, number]> = [];
|
|
746
|
+
const re = new RegExp(URL_SPAN.source, "gi");
|
|
747
|
+
let m = re.exec(prompt);
|
|
748
|
+
while (m !== null) {
|
|
749
|
+
let end = m.index + m[0].length;
|
|
750
|
+
const trailing = m[0].match(URL_TRAILING_PUNCT);
|
|
751
|
+
if (trailing) end -= trailing[0].length;
|
|
752
|
+
out.push([m.index, end]);
|
|
753
|
+
m = re.exec(prompt);
|
|
754
|
+
}
|
|
755
|
+
return out;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/** The sorted clause-delimiter POSITIONS in `prompt`, computed once in a single O(n) pass. The
|
|
759
|
+
* full-scope acknowledgement scan tests each marker occurrence against its surrounding clause; locating
|
|
760
|
+
* that clause by scanning back/forward per occurrence makes the whole scan QUADRATIC in prompt length
|
|
761
|
+
* when a prompt repeats a marker (issue #858 round-11 review — a valid 20,000-char prompt with repeated
|
|
762
|
+
* `full scope` markers was rescanned thousands of times, monopolising the event loop across the
|
|
763
|
+
* allowed 256 nodes). Precomputing the boundaries once and binary-searching them per occurrence keeps
|
|
764
|
+
* the aggregate scan O(n log n) regardless of marker count.
|
|
765
|
+
*
|
|
766
|
+
* A delimiter INSIDE a URL is NOT a boundary (issue #858 round-12 review): treating the `:` in `https:`
|
|
767
|
+
* or the `.` in `github.com` as a boundary split an issue URL, so the attribution/negation checks saw
|
|
768
|
+
* only the fragment before the URL (`The full scope of https`) and missed the assertion that followed it
|
|
769
|
+
* (`… is handled by siblings`). URL spans are detected once and their interior delimiters skipped. */
|
|
770
|
+
function clauseBoundaries(prompt: string): number[] {
|
|
771
|
+
const spans = urlSpans(prompt);
|
|
772
|
+
let spanIdx = 0;
|
|
773
|
+
const out: number[] = [];
|
|
774
|
+
for (let i = 0; i < prompt.length; i++) {
|
|
775
|
+
// Advance past any span that ends at/before i (spans are non-overlapping and in order).
|
|
776
|
+
while (spanIdx < spans.length && spans[spanIdx][1] <= i) spanIdx++;
|
|
777
|
+
const inUrl = spanIdx < spans.length && i >= spans[spanIdx][0] && i < spans[spanIdx][1];
|
|
778
|
+
if (inUrl) continue;
|
|
779
|
+
if (CLAUSE_DELIMITERS.has(prompt.charAt(i))) out.push(i);
|
|
780
|
+
}
|
|
781
|
+
return out;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/** The start of the clause containing `idx`: one past the last delimiter strictly before `idx`. */
|
|
785
|
+
function clauseStartAt(b: number[], idx: number): number {
|
|
786
|
+
let lo = 0;
|
|
787
|
+
let hi = b.length; // first index with b[i] >= idx
|
|
788
|
+
while (lo < hi) {
|
|
789
|
+
const mid = (lo + hi) >> 1;
|
|
790
|
+
if (b[mid] < idx) lo = mid + 1;
|
|
791
|
+
else hi = mid;
|
|
792
|
+
}
|
|
793
|
+
return lo === 0 ? 0 : b[lo - 1] + 1;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/** The end of the clause containing `idx`: the first delimiter at or after `idx` (exclusive). */
|
|
797
|
+
function clauseEndAt(b: number[], idx: number, len: number): number {
|
|
798
|
+
let lo = 0;
|
|
799
|
+
let hi = b.length; // first index with b[i] >= idx
|
|
800
|
+
while (lo < hi) {
|
|
801
|
+
const mid = (lo + hi) >> 1;
|
|
802
|
+
if (b[mid] < idx) lo = mid + 1;
|
|
803
|
+
else hi = mid;
|
|
804
|
+
}
|
|
805
|
+
return lo === b.length ? len : b[lo];
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/** The earliest absolute offset in `[start, end)` at which a `SCOPE_NEGATED_PREFIX` exception/redirection
|
|
809
|
+
* negator COMPLETES, or `+Infinity` when the span holds none. `SCOPE_NEGATED_PREFIX` disqualifies a
|
|
810
|
+
* marker only when a negator sits fully BEFORE it, and it is an UNANCHORED "contains" test monotonic in
|
|
811
|
+
* the marker offset `i` (once a negator is inside `slice(start, i)` it stays inside as `i` grows). So a
|
|
812
|
+
* marker at offset `i` in this clause is prefix-negated iff this value is `<= i` — computing it ONCE per
|
|
813
|
+
* clause (via a single global-regex scan, taking the minimum match-END over every match) lets each of
|
|
814
|
+
* the clause's markers be judged against its OWN prefix, instead of reusing one clause-wide boolean that
|
|
815
|
+
* let the first (shortest-prefix) marker decide every later one (issue #858 round-14 review). The single
|
|
816
|
+
* scan keeps the per-clause cost linear — no O(markers · clause-length) quadratic on a long
|
|
817
|
+
* delimiter-free clause. */
|
|
818
|
+
function earliestNegatorEnd(prompt: string, start: number, end: number): number {
|
|
819
|
+
const clause = prompt.slice(start, end);
|
|
820
|
+
SCOPE_NEGATED_PREFIX_G.lastIndex = 0;
|
|
821
|
+
let min = Number.POSITIVE_INFINITY;
|
|
822
|
+
let m: RegExpExecArray | null = SCOPE_NEGATED_PREFIX_G.exec(clause);
|
|
823
|
+
while (m !== null) {
|
|
824
|
+
const matchEnd = start + m.index + m[0].length;
|
|
825
|
+
if (matchEnd < min) min = matchEnd;
|
|
826
|
+
if (m[0].length === 0) SCOPE_NEGATED_PREFIX_G.lastIndex++; // defensive: never loop on a zero-width match
|
|
827
|
+
m = SCOPE_NEGATED_PREFIX_G.exec(clause);
|
|
828
|
+
}
|
|
829
|
+
return min;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/** The bounded text of the marker's own clause that lies BEFORE the marker occurrence at `idx`, used to
|
|
833
|
+
* test the part-qualifier `PART_QUALIFIER_BEFORE_MARKER` — which narrows only the phrase that FOLLOWS it,
|
|
834
|
+
* so it disqualifies the marker only when it precedes it (issue #858 round-5 review).
|
|
835
|
+
*
|
|
836
|
+
* `PART_QUALIFIER_BEFORE_MARKER` is a `$`-anchored SUFFIX pattern matching a bounded handful of words (a
|
|
837
|
+
* partitive plus `of` and a determiner), so only the trailing few words can match: return at most the
|
|
838
|
+
* last `PREFIX_WINDOW` chars — cut at a word boundary so the leading `\b` never sees a spliced token.
|
|
839
|
+
* Testing the WHOLE growing before-clause per marker made the `$`-anchored regex scan O(clause-length)
|
|
840
|
+
* per marker — O(markers · clause) overall, quadratic on a long delimiter-free clause (issue #858
|
|
841
|
+
* round-12 review).
|
|
842
|
+
*
|
|
843
|
+
* This window is sound ONLY for a `$`-anchored suffix pattern. The OTHER before-marker consumer,
|
|
844
|
+
* `SCOPE_NEGATED_PREFIX`, is an UNANCHORED "contains" test (a negator anywhere in the before-clause
|
|
845
|
+
* disqualifies), so capping it to this window silently drops a negator sitting more than `PREFIX_WINDOW`
|
|
846
|
+
* chars before the marker — a fail-open regression (issue #858 round-12 adversarial review). That
|
|
847
|
+
* consumer therefore tests the WHOLE before-marker text, with the earliest negator offset cached per
|
|
848
|
+
* clause start (see `negatedPrefixEndByClause` / `earliestNegatorEnd` in `isPartialScopeClose`); it does
|
|
849
|
+
* NOT use this bounded window. */
|
|
850
|
+
const PREFIX_WINDOW = 96;
|
|
851
|
+
function clauseBeforeMarker(prompt: string, idx: number, b: number[]): string {
|
|
852
|
+
const start = clauseStartAt(b, idx);
|
|
853
|
+
if (idx - start <= PREFIX_WINDOW) return prompt.slice(start, idx);
|
|
854
|
+
// Cut at a whitespace boundary at/after `idx - PREFIX_WINDOW` so no token is spliced (a mid-token cut
|
|
855
|
+
// could create a spurious `\b` for the consumer's leading anchor).
|
|
856
|
+
let cut = idx - PREFIX_WINDOW;
|
|
857
|
+
while (cut < idx && !/\s/.test(prompt.charAt(cut))) cut++;
|
|
858
|
+
return prompt.slice(cut, idx);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/** A coordinating conjunction that joins an INDEPENDENT additional constraint onto a clause. Only `and`
|
|
862
|
+
* (and its `plus`/`&` kin) qualifies: it is the unambiguous "and also do X" additive conjunction, so a
|
|
863
|
+
* negation in the segment it introduces (`…full scope of #12 AND do not introduce regressions`) governs
|
|
864
|
+
* that other constraint, not the marker. `but`/`or`/`nor` are deliberately EXCLUDED — `but` is the
|
|
865
|
+
* exception idiom's own keyword (`but without covering X` must keep the trailing negation inside the
|
|
866
|
+
* marker's assertion so it still disqualifies), and `or` rarely coordinates an independent constraint in
|
|
867
|
+
* a planner brief. (issue #858 round-6 review) */
|
|
868
|
+
const ASSERTION_COORDINATOR = /\b(?:and|plus)\b|&&?/gi;
|
|
869
|
+
|
|
870
|
+
/** Test-only instrumentation: the number of clause-scan steps (coordinator-threshold sweep iterations +
|
|
871
|
+
* per-marker coordinator examinations) performed by the full-scope acknowledgement scan since the last
|
|
872
|
+
* `resetClauseScanSteps()`. This lets the complexity regression test assert the scan is LINEAR in clause
|
|
873
|
+
* size with a deterministic operation count instead of a nondeterministic wall-clock threshold (issue
|
|
874
|
+
* #858 round-12 review). Not read in production logic. */
|
|
875
|
+
let clauseScanSteps = 0;
|
|
876
|
+
|
|
877
|
+
/** Test-only: read and reset the clause-scan step counter. Returns the count since the previous reset. */
|
|
878
|
+
export function drainClauseScanSteps(): number {
|
|
879
|
+
const n = clauseScanSteps;
|
|
880
|
+
clauseScanSteps = 0;
|
|
881
|
+
return n;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/** Precomputed, per-clause data that lets each marker's delivery-assertion segment be located WITHOUT
|
|
885
|
+
* re-scanning the clause. Round-11 precomputed the delimiter boundaries once and binary-searched them,
|
|
886
|
+
* but `deliveryAssertionAround` still re-ran the coordinator regex over the whole clause and re-derived
|
|
887
|
+
* every coordinator's compound-predicate status for EACH marker — so a long delimiter-free clause with M
|
|
888
|
+
* markers and C coordinators cost O(M·C) slice+scan work, and the old O(tokens²)
|
|
889
|
+
* `isCompoundPredicateContinuation` made it worse (issue #858 round-12 review: a valid 20,000-char
|
|
890
|
+
* `full scope … and …` prompt took minutes across the 256 allowed nodes). This precompute is built ONCE
|
|
891
|
+
* per clause in a single forward pass; each marker then costs O(number of coordinators in its clause)
|
|
892
|
+
* with O(1) compound lookups and no re-slicing. */
|
|
893
|
+
interface ClauseAssertionData {
|
|
894
|
+
/** The clause text (delimiter-bounded). */
|
|
895
|
+
clause: string;
|
|
896
|
+
/** Coordinator occurrences in order: `[bStart, bEnd]` char offsets within `clause`. */
|
|
897
|
+
coords: Array<[number, number]>;
|
|
898
|
+
/** Per coordinator, the minimal MARKER token index at which the text it introduces (`bEnd` up to the
|
|
899
|
+
* marker) becomes a compound-predicate continuation — i.e. the coordinator stops splitting. `Infinity`
|
|
900
|
+
* when the introduced text never leads with a verb. Lets the per-marker compound test be O(1). */
|
|
901
|
+
compoundAtToken: number[];
|
|
902
|
+
/** Token start offsets within `clause` (for mapping a marker char offset to its token index). */
|
|
903
|
+
tokenStarts: number[];
|
|
904
|
+
/** Per token `t`, the smallest token index `s` such that every token in `[s, t)` is an
|
|
905
|
+
* adverb/coordinator (`isSkip`). The "near window" before a marker at token `t` is `[s, t)`; a
|
|
906
|
+
* coordinator ending inside it has an empty/adverbs-only `introduced`, which is exactly when the
|
|
907
|
+
* marker-lead compound test applies. */
|
|
908
|
+
nearStart: number[];
|
|
909
|
+
/** Per coordinator, `tokenIndexAt(bEnd)` — the first token at/after the coordinator's end. */
|
|
910
|
+
coordTok: number[];
|
|
911
|
+
/** Whether each token is an adverb/coordinator (`quickly`/`and`/`or`/`then`/`-ly`). */
|
|
912
|
+
isSkip: boolean[];
|
|
913
|
+
/** The first token index at/after a char offset (binary search over `tokenStarts`). */
|
|
914
|
+
tokenIndexAt: (pos: number) => number;
|
|
915
|
+
// --- Mutable ascending-sweep state (reset per clause; markers are fed in ascending position order) ---
|
|
916
|
+
/** Index into `coords` of the next coordinator not yet pushed onto the far heap. */
|
|
917
|
+
farPtr: number;
|
|
918
|
+
/** Max-heap (by `bEnd`) of far coordinators' `[bEnd, compoundAtToken]`, with lazy expiry. */
|
|
919
|
+
farHeap: Array<[number, number]>;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/** A token that can lead a compound predicate's adverb/coordinator run (`quickly`, `and`, `or`, `then`). */
|
|
923
|
+
const COMPOUND_SKIP_TOKEN = /^(?:\w+ly|and|or|then)$/i;
|
|
924
|
+
/** A token that is a delivery/completion verb lead (the compound predicate's head). Mirrors the verb
|
|
925
|
+
* alternation in `COMPOUND_PREDICATE_LEAD`; matched against a whole token so a trailing `\b` inside the
|
|
926
|
+
* token (e.g. `handle-bar`) still counts, exactly as the sticky lead pattern does. */
|
|
927
|
+
const COMPOUND_VERB_TOKEN =
|
|
928
|
+
/^(?:handle|deliver|cover|own|provide|implement|build|complete|satisfy|address|meet|do|finish|ship|plan|design|scope|close|fix|resolve|add|update|open|write|create|land|merge|test|verify|document)(?:s|es|ed|ing)?\b/i;
|
|
929
|
+
|
|
930
|
+
/** Build the per-clause precompute in ONE forward pass (see `ClauseAssertionData`). Pure string/regex
|
|
931
|
+
* tokenisation plus a monotonic sweep — O(tokens + coordinators) per clause. */
|
|
932
|
+
function buildClauseAssertionData(clause: string): ClauseAssertionData {
|
|
933
|
+
// Tokenise once (whitespace-separated runs), recording each token's start offset.
|
|
934
|
+
const tokenStarts: number[] = [];
|
|
935
|
+
const isSkip: boolean[] = [];
|
|
936
|
+
const isVerb: boolean[] = [];
|
|
937
|
+
{
|
|
938
|
+
const re = /\S+/g;
|
|
939
|
+
let m = re.exec(clause);
|
|
940
|
+
while (m !== null) {
|
|
941
|
+
tokenStarts.push(m.index);
|
|
942
|
+
isSkip.push(COMPOUND_SKIP_TOKEN.test(m[0]));
|
|
943
|
+
isVerb.push(COMPOUND_VERB_TOKEN.test(m[0]));
|
|
944
|
+
m = re.exec(clause);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
const T = tokenStarts.length;
|
|
948
|
+
// nextContent[k] = smallest token index >= k that is NOT an adverb/coordinator (the lead candidate of
|
|
949
|
+
// the suffix starting at k); T when the rest is all adverbs/coordinators.
|
|
950
|
+
const nextContent = new Array<number>(T + 1).fill(T);
|
|
951
|
+
for (let k = T - 1; k >= 0; k--) nextContent[k] = isSkip[k] ? nextContent[k + 1] : k;
|
|
952
|
+
// leadVerbReach[k] = the verb's token index when the suffix starting at k leads with a verb, else Infinity.
|
|
953
|
+
const leadVerbReach = new Array<number>(T).fill(Number.POSITIVE_INFINITY);
|
|
954
|
+
for (let k = 0; k < T; k++) {
|
|
955
|
+
const nc = nextContent[k];
|
|
956
|
+
if (nc < T && isVerb[nc]) leadVerbReach[k] = nc;
|
|
957
|
+
}
|
|
958
|
+
// Coordinator occurrences in order.
|
|
959
|
+
const coords: Array<[number, number]> = [];
|
|
960
|
+
{
|
|
961
|
+
const re = new RegExp(ASSERTION_COORDINATOR.source, "gi");
|
|
962
|
+
let m = re.exec(clause);
|
|
963
|
+
while (m !== null) {
|
|
964
|
+
coords.push([m.index, m.index + m[0].length]);
|
|
965
|
+
m = re.exec(clause);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
// The first token index at/after a char offset (binary search over tokenStarts).
|
|
969
|
+
const tokenIndexAt = (pos: number): number => {
|
|
970
|
+
let lo = 0;
|
|
971
|
+
let hi = T;
|
|
972
|
+
while (lo < hi) {
|
|
973
|
+
const mid = (lo + hi) >> 1;
|
|
974
|
+
if (tokenStarts[mid] < pos) lo = mid + 1;
|
|
975
|
+
else hi = mid;
|
|
976
|
+
}
|
|
977
|
+
return lo;
|
|
978
|
+
};
|
|
979
|
+
// Per coordinator, the marker token index at which it stops splitting when its introduced text is a
|
|
980
|
+
// compound-predicate continuation. The introduced text must be VERB-LED — its FIRST content token,
|
|
981
|
+
// after skipping a leading adverb/coordinator run, is a completion verb (`and deliver …`, `and quickly
|
|
982
|
+
// own …`). `leadVerbReach[te]` answers exactly that lead question for the suffix starting at the
|
|
983
|
+
// coordinator's first introduced token `te`: it is the verb's token index (finite) iff the introduced
|
|
984
|
+
// text leads with a verb, else `Infinity`.
|
|
985
|
+
//
|
|
986
|
+
// Testing only the LEAD (not any later suffix) is the correct semantics (issue #858 round-13 review):
|
|
987
|
+
// scanning every later token until SOME suffix reaches a verb misclassifies an INDEPENDENT clause whose
|
|
988
|
+
// verb merely sits later (`… and this slice delivers the full scope of #12`) as a compound predicate,
|
|
989
|
+
// retaining an unrelated earlier negation/attribution; and that nested scan was itself O(coords ·
|
|
990
|
+
// tokens) on a clause whose introduced text never leads with a verb. When the introduced text leads
|
|
991
|
+
// with a verb the coordinator shares the prior subject for EVERY marker at/after the first introduced
|
|
992
|
+
// token, so the threshold is the constant `te + 1` (the marker sits at/after token `te`); when it does
|
|
993
|
+
// not, the coordinator always splits (`Infinity`). This is O(1) per coordinator — no sweep.
|
|
994
|
+
const compoundAtToken = coords.map(([, bEnd]) => {
|
|
995
|
+
const te = tokenIndexAt(bEnd);
|
|
996
|
+
return te < T && leadVerbReach[te] !== Number.POSITIVE_INFINITY ? te + 1 : Number.POSITIVE_INFINITY;
|
|
997
|
+
});
|
|
998
|
+
// nearStart[t] = smallest token index `s` such that tokens [s, t) are all adverb/coordinator (`isSkip`).
|
|
999
|
+
// Computed in one forward pass: as t advances, the window's left edge resets to t whenever token t-1 is
|
|
1000
|
+
// NOT a skip token, and otherwise extends.
|
|
1001
|
+
const nearStart = new Array<number>(T + 1).fill(0);
|
|
1002
|
+
{
|
|
1003
|
+
let left = 0;
|
|
1004
|
+
for (let t = 0; t <= T; t++) {
|
|
1005
|
+
if (t > 0 && !isSkip[t - 1]) left = t;
|
|
1006
|
+
nearStart[t] = left;
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
const coordTok = coords.map(([, bEnd]) => tokenIndexAt(bEnd));
|
|
1010
|
+
return {
|
|
1011
|
+
clause,
|
|
1012
|
+
coords,
|
|
1013
|
+
compoundAtToken,
|
|
1014
|
+
tokenStarts,
|
|
1015
|
+
nearStart,
|
|
1016
|
+
coordTok,
|
|
1017
|
+
isSkip,
|
|
1018
|
+
tokenIndexAt,
|
|
1019
|
+
farPtr: 0,
|
|
1020
|
+
farHeap: [],
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
/** Max-heap (by element `[0]`, the coordinator `bEnd`) operations for the far-coordinator sweep. A tiny
|
|
1025
|
+
* binary heap; expiry of compound coordinators is LAZY (skipped at the top on read), so a coordinator is
|
|
1026
|
+
* pushed/popped at most once per clause — keeping the ascending marker sweep O((markers + coordinators)
|
|
1027
|
+
* log coordinators) per clause instead of O(markers · coordinators). */
|
|
1028
|
+
function heapPush(heap: Array<[number, number]>, item: [number, number]): void {
|
|
1029
|
+
heap.push(item);
|
|
1030
|
+
let i = heap.length - 1;
|
|
1031
|
+
while (i > 0) {
|
|
1032
|
+
const parent = (i - 1) >> 1;
|
|
1033
|
+
if (heap[parent][0] >= heap[i][0]) break;
|
|
1034
|
+
[heap[parent], heap[i]] = [heap[i], heap[parent]];
|
|
1035
|
+
i = parent;
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
function heapPop(heap: Array<[number, number]>): void {
|
|
1040
|
+
const last = heap.pop();
|
|
1041
|
+
if (heap.length === 0 || last === undefined) return;
|
|
1042
|
+
heap[0] = last;
|
|
1043
|
+
let i = 0;
|
|
1044
|
+
for (;;) {
|
|
1045
|
+
const l = 2 * i + 1;
|
|
1046
|
+
const r = 2 * i + 2;
|
|
1047
|
+
let m = i;
|
|
1048
|
+
if (l < heap.length && heap[l][0] > heap[m][0]) m = l;
|
|
1049
|
+
if (r < heap.length && heap[r][0] > heap[m][0]) m = r;
|
|
1050
|
+
if (m === i) break;
|
|
1051
|
+
[heap[m], heap[i]] = [heap[i], heap[m]];
|
|
1052
|
+
i = m;
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/** The marker's DELIVERY ASSERTION: its own comma-bounded clause, further narrowed to the
|
|
1057
|
+
* `and`-coordinated segment the marker occurrence at `[markerStart,markerEnd)` sits in. Used by the
|
|
1058
|
+
* whole-assertion negation disqualifiers (`SCOPE_NEGATED_CORE`, `SCOPE_NEGATED_WITHOUT_DELIVERY`) so a
|
|
1059
|
+
* negator governing an UNRELATED constraint coordinated onto the clause by `and` does not disqualify the
|
|
1060
|
+
* marker, while a negator in the marker's own segment still does (issue #858 round-6 review). A negator
|
|
1061
|
+
* directly on the marker (`does not deliver the full scope`) has no coordinator between it and the
|
|
1062
|
+
* marker, so it stays in-segment and still disqualifies; the fail-closed default is the whole clause
|
|
1063
|
+
* when no coordinator splits it.
|
|
1064
|
+
*
|
|
1065
|
+
* `cache` memoises `buildClauseAssertionData` per clause start so the many markers sharing one clause do
|
|
1066
|
+
* not each rebuild it (issue #858 round-12 review).
|
|
1067
|
+
*
|
|
1068
|
+
* Returns the segment text AND its absolute (`prompt`-relative) `[absStart, absEnd)` bounds. The bounds
|
|
1069
|
+
* let the caller group occurrences that share a segment start: those segments are nested prefixes
|
|
1070
|
+
* (same start, growing end), so a "contains" disqualifier regex over them is MONOTONIC in the end offset
|
|
1071
|
+
* — the basis for the per-group memoisation in `isPartialScopeClose` (issue #858 round-12 review). */
|
|
1072
|
+
function deliveryAssertionAround(
|
|
1073
|
+
prompt: string,
|
|
1074
|
+
markerStart: number,
|
|
1075
|
+
markerEnd: number,
|
|
1076
|
+
b: number[],
|
|
1077
|
+
cache: Map<number, ClauseAssertionData>,
|
|
1078
|
+
): { text: string; absStart: number; absEnd: number } {
|
|
1079
|
+
const cStart = clauseStartAt(b, markerStart);
|
|
1080
|
+
let data = cache.get(cStart);
|
|
1081
|
+
if (data === undefined) {
|
|
1082
|
+
data = buildClauseAssertionData(prompt.slice(cStart, clauseEndAt(b, markerStart, prompt.length)));
|
|
1083
|
+
cache.set(cStart, data);
|
|
1084
|
+
}
|
|
1085
|
+
const { clause, coords, compoundAtToken, nearStart, coordTok, tokenIndexAt } = data;
|
|
1086
|
+
// The clause spans the marker; locate the marker's offset within it (the clause start is the first
|
|
1087
|
+
// delimiter boundary at or before markerStart).
|
|
1088
|
+
const relStart = markerStart - cStart;
|
|
1089
|
+
const relEnd = markerEnd - cStart;
|
|
1090
|
+
// The marker's token index: the first token starting at/after relStart. Markers are whole words found
|
|
1091
|
+
// by substring scan, so relStart is always a token start.
|
|
1092
|
+
const markerToken = tokenIndexAt(relStart);
|
|
1093
|
+
|
|
1094
|
+
// The marker's assertion segment is `[segStart, segEnd)`: segStart is the end of the LAST coordinator
|
|
1095
|
+
// before the marker that genuinely SPLITS (is not a compound-predicate continuation), segEnd the start
|
|
1096
|
+
// of the first coordinator at/after the marker. A coordinator BEFORE the marker normally splits, but a
|
|
1097
|
+
// coordinator introducing a COMPOUND PREDICATE — a bare verb phrase with no new subject — shares the
|
|
1098
|
+
// prior segment's subject, so it must NOT split (else the active-voice attribution check loses the
|
|
1099
|
+
// subject; issue #858 round-9 review).
|
|
1100
|
+
//
|
|
1101
|
+
// Markers are fed in ASCENDING position order, so this is a single forward sweep per clause (issue
|
|
1102
|
+
// #858 round-12 review). Coordinators split into two sets relative to the marker:
|
|
1103
|
+
// - FAR coordinators end before the marker's "near window" (the trailing adverb/coordinator run), so
|
|
1104
|
+
// their introduced text contains a real (non-adverb) token and the marker-lead test canNOT apply —
|
|
1105
|
+
// their compound status is purely the precomputed threshold. The max valid far `bEnd` is tracked on
|
|
1106
|
+
// a max-heap, advanced and expired monotonically.
|
|
1107
|
+
// - NEAR coordinators end inside the near window (empty/adverbs-only introduced), so the marker-lead
|
|
1108
|
+
// test (mlc) CAN apply; there are only as many as the adverb run is long, so they are checked
|
|
1109
|
+
// individually.
|
|
1110
|
+
const nStart = nearStart[markerToken];
|
|
1111
|
+
|
|
1112
|
+
// FAR set: coordinators with coordTok <= nStart-1 (they end at/before the near window's start, so a
|
|
1113
|
+
// non-skip token lies between them and the marker). Push newly-in-range coordinators; expire those whose
|
|
1114
|
+
// compound threshold the marker has passed (they no longer split). The heap top is the max valid bEnd.
|
|
1115
|
+
while (data.farPtr < coords.length && coordTok[data.farPtr] <= nStart - 1) {
|
|
1116
|
+
heapPush(data.farHeap, [coords[data.farPtr][1], compoundAtToken[data.farPtr]]);
|
|
1117
|
+
data.farPtr++;
|
|
1118
|
+
}
|
|
1119
|
+
while (data.farHeap.length > 0 && data.farHeap[0][1] <= markerToken) heapPop(data.farHeap);
|
|
1120
|
+
const far = data.farHeap.length > 0 ? data.farHeap[0][0] : 0;
|
|
1121
|
+
|
|
1122
|
+
// NEAR set: coordinators from farPtr onward whose bEnd <= relStart (they end inside the near window or
|
|
1123
|
+
// right at the marker). These are few (the adverb run is short); test each with the full compound rule.
|
|
1124
|
+
let near = 0;
|
|
1125
|
+
for (let ci = data.farPtr; ci < coords.length; ci++) {
|
|
1126
|
+
clauseScanSteps++; // test instrumentation: one near-coordinator examined per marker
|
|
1127
|
+
const bEnd = coords[ci][1];
|
|
1128
|
+
if (bEnd > relStart) break; // coords are ordered; past the marker
|
|
1129
|
+
// The marker's OWN lead is the continuation when the coordinator sits right at the marker
|
|
1130
|
+
// (`… and OWN the whole`, `introduced` empty) OR when only adverbs/coordinators intervene
|
|
1131
|
+
// (`… and QUICKLY own the whole`). In both the predicate's head verb is inside the marker, so test
|
|
1132
|
+
// the marker's lead.
|
|
1133
|
+
const introduced = clause.slice(bEnd, relStart);
|
|
1134
|
+
const markerLeadIsCompound =
|
|
1135
|
+
(introduced.trim() === "" || /^(?:\s*(?:\w+ly|and|or|then))*\s*$/.test(introduced)) &&
|
|
1136
|
+
isCompoundPredicateContinuation(clause.slice(relStart, relEnd));
|
|
1137
|
+
const compound = markerToken >= compoundAtToken[ci] || markerLeadIsCompound;
|
|
1138
|
+
if (bEnd > near && !compound) {
|
|
1139
|
+
near = bEnd;
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
const segStart = Math.max(far, near);
|
|
1143
|
+
|
|
1144
|
+
// segEnd: the start of the first coordinator at/after the marker's end (binary search — coords are
|
|
1145
|
+
// ordered by bStart).
|
|
1146
|
+
let segEnd = clause.length;
|
|
1147
|
+
{
|
|
1148
|
+
let lo = 0;
|
|
1149
|
+
let hi = coords.length;
|
|
1150
|
+
while (lo < hi) {
|
|
1151
|
+
const mid = (lo + hi) >> 1;
|
|
1152
|
+
if (coords[mid][0] < relEnd) lo = mid + 1;
|
|
1153
|
+
else hi = mid;
|
|
1154
|
+
}
|
|
1155
|
+
if (lo < coords.length) segEnd = coords[lo][0];
|
|
1156
|
+
}
|
|
1157
|
+
// Return ABSOLUTE (prompt-relative) bounds so the caller can group occurrences sharing a segment start.
|
|
1158
|
+
return { text: clause.slice(segStart, segEnd), absStart: cStart + segStart, absEnd: cStart + segEnd };
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
/** True when the text between a coordinator and the marker is a COMPOUND PREDICATE continuation — a bare
|
|
1162
|
+
* verb phrase (optionally adverb-led) with NO subject noun of its own. `Siblings plan and DELIVER the
|
|
1163
|
+
* full scope` has `deliver` right after `and` (a verb, no new subject), so the `and` shares the prior
|
|
1164
|
+
* subject `Siblings`. By contrast `… and THE REGRESSION SUITE is handled …` leads with a determiner/noun
|
|
1165
|
+
* (a new subject), so it is an independent constraint, not a compound predicate. The heuristic: the
|
|
1166
|
+
* continuation leads with an optional adverb then a VERB (a delivery/completion verb or a generic
|
|
1167
|
+
* `-ing`/`-s`/base verb), never a determiner/pronoun/noun. Conservative — it only suppresses the split
|
|
1168
|
+
* when the lead word is clearly verb-like, so an independent constraint with a nominal subject still
|
|
1169
|
+
* splits (issue #858 round-9 review). */
|
|
1170
|
+
const COMPOUND_PREDICATE_LEAD =
|
|
1171
|
+
/^\s*(?:(?:\w+ly|and|or|then)\s+)*(?:handle|deliver|cover|own|provide|implement|build|complete|satisfy|address|meet|do|finish|ship|plan|design|scope|close|fix|resolve|add|update|open|write|create|land|merge|test|verify|document)(?:s|es|ed|ing)?\b/i;
|
|
1172
|
+
/** Sticky (anchored at `lastIndex`) form of the lead test, WITHOUT the leading `^\s*` — the caller
|
|
1173
|
+
* positions `lastIndex` at a token start. Derived from `COMPOUND_PREDICATE_LEAD.source` (single source of
|
|
1174
|
+
* truth — no divergent copy of the verb list) by stripping the leading `^\s*` anchor and adding the
|
|
1175
|
+
* sticky flag. Used by the linear token scan in `isCompoundPredicateContinuation` so testing every
|
|
1176
|
+
* token-suffix of a segment is O(tokens), not the O(tokens²) of re-joining and re-matching each suffix
|
|
1177
|
+
* (`tokens.slice(k).join(" ")` rebuilt a fresh string per suffix — issue #858 round-12 review: on a long
|
|
1178
|
+
* delimiter-free clause that per-suffix re-join made the whole full-scope scan superlinear). */
|
|
1179
|
+
const COMPOUND_PREDICATE_LEAD_STICKY = new RegExp(
|
|
1180
|
+
// Strip the leading `^` anchor and the `\s*` run (the caller positions lastIndex at a token start).
|
|
1181
|
+
COMPOUND_PREDICATE_LEAD.source.replace(/^\^/, "").replace(/^\\s\*/, ""),
|
|
1182
|
+
"iy",
|
|
1183
|
+
);
|
|
1184
|
+
// Reference the anchored form so the canonical pattern stays a live symbol (the sticky derivative is the
|
|
1185
|
+
// one used in the hot scan); this also guards the derivation above against a source drift that drops the
|
|
1186
|
+
// anchor the `.replace` expects.
|
|
1187
|
+
void COMPOUND_PREDICATE_LEAD;
|
|
1188
|
+
function isCompoundPredicateContinuation(between: string): boolean {
|
|
1189
|
+
// A compound predicate is a bare VERB PHRASE with no subject of its own. It can be verb-led
|
|
1190
|
+
// (`and deliver …`), adverb-led (`and carefully deliver …`), or a coordinator+adverb chain whose
|
|
1191
|
+
// LAST token is the verb (`and quickly own …`, `and then handle …`) — the verb need not be the FIRST
|
|
1192
|
+
// word, only the predicate's head. So test the lead at the start AND after each adverb/coordinator
|
|
1193
|
+
// boundary: if any suffix leads with a completion verb, the segment is a compound continuation and
|
|
1194
|
+
// the coordinator shares the prior subject (issue #858 round-9 adversarial review — requiring the
|
|
1195
|
+
// verb first dropped the subject for `design and quickly own`, re-opening the attribution bypass).
|
|
1196
|
+
//
|
|
1197
|
+
// Test each token start with the STICKY lead pattern (O(1) per position, O(tokens) total) instead of
|
|
1198
|
+
// rebuilding every suffix string (O(tokens²)). Behaviour-identical to the suffix-join form: the lead
|
|
1199
|
+
// pattern only inspects a leading run of adverb/coordinator tokens then one verb, so anchoring it at
|
|
1200
|
+
// each token start matches exactly the suffixes the old loop re-joined (verified by differential fuzz).
|
|
1201
|
+
const n = between.length;
|
|
1202
|
+
let i = 0;
|
|
1203
|
+
while (i < n) {
|
|
1204
|
+
while (i < n && /\s/.test(between.charAt(i))) i++; // skip whitespace to the next token start
|
|
1205
|
+
if (i >= n) break;
|
|
1206
|
+
COMPOUND_PREDICATE_LEAD_STICKY.lastIndex = i;
|
|
1207
|
+
if (COMPOUND_PREDICATE_LEAD_STICKY.test(between)) return true;
|
|
1208
|
+
while (i < n && !/\s/.test(between.charAt(i))) i++; // advance past this token
|
|
1209
|
+
}
|
|
1210
|
+
return false;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/** A whole-scope marker narrowed by a PREFIX PARTITIVE ("half of every acceptance criterion", "part of
|
|
1214
|
+
* the whole issue", "a subset of the full scope") scopes the acknowledgement DOWN to a part, so it must
|
|
1215
|
+
* NOT licence a close — the mirror of `PART_QUALIFIER_AFTER_MARKER` on the LEADING side (issue #858
|
|
1216
|
+
* round-5 review: "detect partial-scope qualifiers before the issue marker"). Matches a partitive
|
|
1217
|
+
* quantifier at the END of the marker's before-clause text, so it is adjacent to the marker. The `of`
|
|
1218
|
+
* is OPTIONAL: the most common English partitive drops it ("half the full scope", "part the whole
|
|
1219
|
+
* issue", "half my scope"), so requiring a literal `of` let exactly those siblings bypass the guard
|
|
1220
|
+
* (issue #858 round-5 adversarial review). An optional determiner/possessive (`the`/`its`/`my`/`our`/…)
|
|
1221
|
+
* may sit between the partitive and the marker. Whole quantifiers (`all of`, `the whole of`) are
|
|
1222
|
+
* deliberately excluded — they denote the WHOLE, not a part — and an unrelated earlier "… of …" ("as
|
|
1223
|
+
* part of the milestone, deliver the full scope …") is not adjacent to the marker, so it does not
|
|
1224
|
+
* disqualify. */
|
|
1225
|
+
const PART_QUALIFIER_BEFORE_MARKER =
|
|
1226
|
+
/\b(?:(?:a|one|two|three|four|five)\s+)?(?:half|part|portion|some|subset|fraction|piece|bit|chunk|segment|slice|section|fragment|sliver|handful|couple|number|few|several|most|many|much|majority|minority|remainder|rest)\s+(?:of\s+)?(?:(?:the|its|this|that|each|every|all|a|my|our|your|their|his|her)\s+)?$/i;
|
|
1227
|
+
|
|
1228
|
+
/** A whole-scope marker immediately followed by a PART-QUALIFIER scopes the acknowledgement DOWN to a
|
|
1229
|
+
* part, so it must NOT licence a close (issue #858 round-3 adversarial review). Matching is a bare
|
|
1230
|
+
* substring, so "the whole issue's parser slice", "all of #12's backend", and "every acceptance
|
|
1231
|
+
* criterion's auth half" all credit the marker even though each describes a PARTIAL deliverable — the
|
|
1232
|
+
* exact defect class the guard exists to catch. This pattern matches the text IMMEDIATELY after a
|
|
1233
|
+
* marker occurrence when that text is a possessive (`'s <part>`) or partitive (`of <part>` / `of the
|
|
1234
|
+
* <part>`) that narrows the whole to one slice. The marker is anchored at its end (`end`), so the
|
|
1235
|
+
* qualifier must be adjacent — a part-word appearing LATER in the clause ("the whole issue #12,
|
|
1236
|
+
* including the parser slice") does NOT disqualify. The part-word list is the vocabulary a planner
|
|
1237
|
+
* uses to name a sub-scope; it is deliberately broad (any of these words right after the marker means
|
|
1238
|
+
* the acknowledgement is not whole-scope). */
|
|
1239
|
+
const PART_QUALIFIER_AFTER_MARKER =
|
|
1240
|
+
/^(?:['’]s|of)\s+(?:(?:the|a|an|one|first|second|third|single|only|just)\s+){0,2}(?:parser|slice|part|portion|half|backend|frontend|auth|criteri(?:on|a)|checkbox|front|back|ui|api|db|database|server|client|component|module|piece|section|stage|step|phase|bit|chunk|segment|subset|subpart|aspect|layer|tier|side|edge|corner|fragment|shard|sliver|remnant|rest|remainder)\b/i;
|
|
1241
|
+
|
|
1242
|
+
/** True when the text immediately after a whole-scope marker occurrence (at `[start,end)`) is a
|
|
1243
|
+
* part-qualifier that scopes the acknowledgement DOWN to a part — disqualifying the occurrence so it
|
|
1244
|
+
* cannot licence a close. The qualifier can follow the marker directly ("the whole issue's parser
|
|
1245
|
+
* slice"), follow a connective word the marker is a prefix of ("own the whole issue's backend" — the
|
|
1246
|
+
* marker "own the whole" ends before "issue"), or follow the `#N` anchor the marker attaches to ("all
|
|
1247
|
+
* of #12's backend"). So we first consume any run of connective/anchor tokens (whitespace, `issue`,
|
|
1248
|
+
* `scope`, `of`, `the`, `#N`) and then test for the part-qualifier at each step. */
|
|
1249
|
+
function isPartQualified(prompt: string, end: number): boolean {
|
|
1250
|
+
const rest = prompt.slice(end, end + 64);
|
|
1251
|
+
if (PART_QUALIFIER_AFTER_MARKER.test(rest)) return true;
|
|
1252
|
+
// Walk past one connective/anchor token at a time, re-testing for the qualifier after each, so a
|
|
1253
|
+
// qualifier that follows "issue" / "scope" / "#N" (etc.) right after the marker still disqualifies.
|
|
1254
|
+
const step = /^(\s+|issue\b|scope\b|of\b|the\b|#[0-9]+|[0-9]+)/i;
|
|
1255
|
+
let offset = 0;
|
|
1256
|
+
for (let n = 0; n < 4; n++) {
|
|
1257
|
+
const m = rest.slice(offset).match(step);
|
|
1258
|
+
if (!m) break;
|
|
1259
|
+
offset += m[0].length;
|
|
1260
|
+
if (PART_QUALIFIER_AFTER_MARKER.test(rest.slice(offset))) return true;
|
|
1261
|
+
}
|
|
1262
|
+
return false;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
/** A TRAILING scope-EXCLUSION connective (`except`/`excluding`/`excluded`/`omitting`/`other than`/
|
|
1266
|
+
* `apart from`/`aside from`/`but not`/`save for`/`with the exception of`/`minus`) that narrows a whole-scope marker DOWN to "the
|
|
1267
|
+
* whole MINUS a named part" — so it must NOT licence a close (issue #858 round-15 review). This is the
|
|
1268
|
+
* AFTER-anchor mirror of the before-marker `SCOPE_NEGATED_PREFIX` exception vocabulary: in `Deliver the
|
|
1269
|
+
* full scope of #12, excluding the parser; close #12.` the exclusion sits AFTER the marker's `#12`
|
|
1270
|
+
* anchor and the comma ends the marker's clause before it, so neither the clause-bounded
|
|
1271
|
+
* `SCOPE_NEGATED_AFTER_MARKER_DELIVERY` re-catch nor the before-marker `SCOPE_NEGATED_PREFIX` ever sees
|
|
1272
|
+
* it, and the explicitly-partial brief validated. Keyed `^\s*,?\s*…` so it may cross at most ONE comma
|
|
1273
|
+
* (the clause boundary the exclusion typically follows); the negative lookahead keeps an EXCLUSION OF
|
|
1274
|
+
* NOTHING (`excluding nothing`, `except none`, `other than no part`) affirmative — that still delivers
|
|
1275
|
+
* the whole scope. A BARE trailing `but` (`…, but split across two commits`) is deliberately NOT here —
|
|
1276
|
+
* only the `but not` exception idiom is — mirroring `SCOPE_NEGATED_PREFIX`, which leaves bare `but` out
|
|
1277
|
+
* as a common affirmative conjunction. Trailing `without`/`rather than`/`instead of` are likewise
|
|
1278
|
+
* excluded: those govern a manner/constraint and stay affirmative (the `without <delivery gerund>` case
|
|
1279
|
+
* is already caught by `SCOPE_NEGATED_WITHOUT_DELIVERY`). */
|
|
1280
|
+
const SCOPE_EXCLUSION_LEAD =
|
|
1281
|
+
/^\s*,?\s*\b(?:except(?:ing|\s+for)?|exclud(?:e|es|ed|ing)|omit(?:s|ted|ting)?|other\s+than|apart\s+from|aside\s+from|save\s+for|but\s+not|with\s+the\s+exception\s+of|minus)\b\s+(?!nothing\b|none\b|no\b|any\s+other\b)\S/i;
|
|
1282
|
+
|
|
1283
|
+
/** True when a whole-scope marker ending at `end` is narrowed by a TRAILING exclusion phrase that sits
|
|
1284
|
+
* after the marker's issue anchor (and at most one comma). We walk past the anchor run — whitespace,
|
|
1285
|
+
* `of`/`the`/`issue`/`scope`/`'s`, a bare or repo-qualified `#N`, or an issue URL — re-testing for the
|
|
1286
|
+
* exclusion connective after each token, so `full scope of #12, excluding the parser` disqualifies even
|
|
1287
|
+
* though the exclusion is several anchor tokens and a comma past the marker. The walk is bounded (at
|
|
1288
|
+
* most 8 anchor tokens) and `SCOPE_EXCLUSION_LEAD` crosses at most one comma, so a distant `excluding`
|
|
1289
|
+
* governing an unrelated later phrase cannot reach back and disqualify this marker. */
|
|
1290
|
+
function isExclusionQualified(prompt: string, end: number): boolean {
|
|
1291
|
+
const rest = prompt.slice(end, end + 96);
|
|
1292
|
+
const step = /^(?:\s+|['’]s|of\b|the\b|issue\b|scope\b|#?\s*[0-9]+|[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+|https?:\/\/[^\s)]+)/i;
|
|
1293
|
+
let offset = 0;
|
|
1294
|
+
for (let n = 0; n < 8; n++) {
|
|
1295
|
+
if (SCOPE_EXCLUSION_LEAD.test(rest.slice(offset))) return true;
|
|
1296
|
+
const m = rest.slice(offset).match(step);
|
|
1297
|
+
if (!m || m[0].length === 0) break;
|
|
1298
|
+
offset += m[0].length;
|
|
1299
|
+
}
|
|
1300
|
+
return false;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
/** The repo-qualified issue KEY (see `issueKey`) ANCHORED to a whole-scope phrase occupying
|
|
1304
|
+
* `[start,end)` in `prompt`, or null if none is adjacent. Checks, in order: a `#N` (optionally
|
|
1305
|
+
* `owner/repo#N`) or an issue URL (`…/owner/repo/issues/N`) immediately AFTER the phrase (through at
|
|
1306
|
+
* most a few connective words — "of", "the", "issue"; or directly, for markers that already end in
|
|
1307
|
+
* `#`), then a possessive/adjacent `owner/repo#N`, bare `#N`, or issue URL immediately BEFORE it.
|
|
1308
|
+
* Issue URLs are a first-class issue reference (parsed by `issueRefsIn`/`closingTargets`), so the
|
|
1309
|
+
* anchor must recognise them too — otherwise a prompt whose only full-scope acknowledgement is
|
|
1310
|
+
* URL-anchored, and which references a second issue (so the sole-issue fallback is unavailable), is
|
|
1311
|
+
* wrongly rejected (issue #858 round-5 review). Proximity is what ties the acknowledgement to a
|
|
1312
|
+
* specific issue, so a phrase next to `owner/alpha#12` (or its URL) cannot license closing
|
|
1313
|
+
* `owner/beta#12` (or a bare `#12`) in the same clause — the repository prefix is preserved, not
|
|
1314
|
+
* collapsed to the bare number. */
|
|
1315
|
+
function anchoredIssueKey(prompt: string, start: number, end: number): string | null {
|
|
1316
|
+
const after = prompt.slice(end, end + 96);
|
|
1317
|
+
// Lex the candidate reference that immediately FOLLOWS the marker (through at most a few connective
|
|
1318
|
+
// words — "of", "the", "issue"), with NO digit-capture group (the single-grammar guard bans a local
|
|
1319
|
+
// `#(<digits>`/`/issues/(<digits>` capture), then classify it through the canonical parser
|
|
1320
|
+
// (`issueRefKey`). A marker that already ends in `#` is anchored to the bare number that follows.
|
|
1321
|
+
if (prompt[end - 1] === "#") {
|
|
1322
|
+
const num = after.match(/^[0-9]+/);
|
|
1323
|
+
if (num) return issueKey(null, num[0]);
|
|
1324
|
+
} else {
|
|
1325
|
+
const afterRef = after.match(/^\s*(?:(?:of|the|issue)\s+){0,3}(https?:\/\/[^\s)]+|[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+#[0-9]+|#[0-9]+)/i);
|
|
1326
|
+
if (afterRef) {
|
|
1327
|
+
const key = issueRefKey(afterRef[1]);
|
|
1328
|
+
if (key) return key;
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
const before = prompt.slice(Math.max(0, start - 96), start);
|
|
1332
|
+
// The reference immediately BEFORE the marker (a possessive/adjacent `owner/repo#N`, bare `#N`, or an
|
|
1333
|
+
// issue URL), optionally followed by `'s`/`of`. Lexed capture-free, classified via `issueRefKey`.
|
|
1334
|
+
const beforeRef = before.match(/(https?:\/\/[^\s)]+|[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+#[0-9]+|#[0-9]+)(?:['’]s|s['’]|['’])?\s*(?:of\s+)?$/i);
|
|
1335
|
+
if (beforeRef) {
|
|
1336
|
+
const key = issueRefKey(beforeRef[1]);
|
|
1337
|
+
if (key) return key;
|
|
1338
|
+
}
|
|
1339
|
+
return null;
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
function isPartialScopeClose(prompt: string): boolean {
|
|
1343
|
+
if (!ISSUE_REF_PATTERN.test(prompt)) return false;
|
|
1344
|
+
|
|
1345
|
+
// The AUTHORITATIVE closing targets (negation-aware): a directly-negated close (`do not close #12`)
|
|
1346
|
+
// is not a target. If every close in the prompt is negated — or there is no closing LANGUAGE at all —
|
|
1347
|
+
// there is no ACTIVE close to guard, so the brief is a safe partial-slice brief, not a
|
|
1348
|
+
// partial-scope-close (issue #858 round-7 review). `closingTargets` is the SINGLE source of the
|
|
1349
|
+
// closing grammar (`CLOSING_TARGET_PATTERN`); there is no separate pre-filter to drift from it
|
|
1350
|
+
// (issue #858 round-16 review removed the duplicate `CLOSING_ACTION_PATTERN`).
|
|
1351
|
+
const { numbered, pronoun } = closingTargets(prompt);
|
|
1352
|
+
if (numbered.length === 0 && !pronoun) return false;
|
|
1353
|
+
|
|
1354
|
+
const distinct = new Set(issueRefsIn(prompt));
|
|
1355
|
+
const sole = distinct.size === 1 ? [...distinct][0] : null;
|
|
1356
|
+
|
|
1357
|
+
// Which issues does the brief genuinely acknowledge owning the FULL scope of? Scan each whole-scope
|
|
1358
|
+
// phrase occurrence, anchor it to the issue immediately adjacent to it (or the sole issue), and skip
|
|
1359
|
+
// any occurrence whose clause attributes the scope to others, NEGATES/disclaims it ("does not deliver
|
|
1360
|
+
// the full scope of #12"), or that is immediately qualified DOWN to a part ("the whole issue's parser
|
|
1361
|
+
// slice") — each is a non-assertion, not a whole-scope acknowledgement.
|
|
1362
|
+
const lower = prompt.toLowerCase();
|
|
1363
|
+
const acknowledged = new Set<string>();
|
|
1364
|
+
// Clause-delimiter positions, computed ONCE (issue #858 round-11 review): the per-occurrence
|
|
1365
|
+
// disqualifiers below each locate the marker's clause, and doing that by scanning back/forward per
|
|
1366
|
+
// occurrence makes the whole scan quadratic in prompt length when a marker repeats. Binary-searching
|
|
1367
|
+
// the precomputed boundaries keeps it O(n log n).
|
|
1368
|
+
const bounds = clauseBoundaries(prompt);
|
|
1369
|
+
// Per-clause coordinator/segment precompute, built lazily ONCE per clause and shared across every
|
|
1370
|
+
// marker occurrence in that clause (issue #858 round-12 review): without it, a long delimiter-free
|
|
1371
|
+
// clause with many markers re-derives each coordinator's compound-predicate status per marker, which is
|
|
1372
|
+
// quadratic in clause length.
|
|
1373
|
+
const assertionCache = new Map<number, ClauseAssertionData>();
|
|
1374
|
+
// Collect every whole-scope marker occurrence across all marker phrases, then process them in
|
|
1375
|
+
// ASCENDING position order. The per-clause sweeper (`deliveryAssertionAround`) advances a coordinator
|
|
1376
|
+
// pointer and a far/near split monotonically as the marker position advances, so feeding occurrences
|
|
1377
|
+
// in ascending order keeps the whole scan LINEAR in clause size (issue #858 round-12 review); feeding
|
|
1378
|
+
// them phrase-by-phrase (all of one marker, then the next) would revisit earlier positions and break
|
|
1379
|
+
// the monotonic sweep.
|
|
1380
|
+
const occurrences: Array<[number, number]> = [];
|
|
1381
|
+
for (const marker of FULL_SCOPE_MARKERS) {
|
|
1382
|
+
for (let i = lower.indexOf(marker); i >= 0; i = lower.indexOf(marker, i + marker.length)) {
|
|
1383
|
+
occurrences.push([i, i + marker.length]);
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
occurrences.sort((a, b) => a[0] - b[0]);
|
|
1387
|
+
// `SCOPE_NEGATED_AFTER_MARKER_DELIVERY` re-catches a negator that sits AFTER the marker yet still
|
|
1388
|
+
// disclaims its scope (`…full scope of #12 AND it is not fully delivered`). It must therefore run
|
|
1389
|
+
// against the MARKER-RELATIVE SUFFIX — the text from the marker to the clause end — NOT the whole
|
|
1390
|
+
// clause: scanning the text BEFORE the marker matched a negator that PRECEDES it (`The tests must not
|
|
1391
|
+
// regress and this slice delivers the full scope of #12` matches `not … delivers`), rejecting a
|
|
1392
|
+
// legitimate acknowledgement for a negation that governs an unrelated earlier constraint (issue #858
|
|
1393
|
+
// round-13 review). The suffix differs per marker, so memoise the boolean keyed by the marker's own
|
|
1394
|
+
// offset `i` — re-running the backtracking regex per marker over a long clause would be O(markers ·
|
|
1395
|
+
// clause-length), quadratic on a long delimiter-free clause (issue #858 round-12 review).
|
|
1396
|
+
const negatedAfterMarkerByMarker = new Map<number, boolean>();
|
|
1397
|
+
// `SCOPE_NEGATED_PREFIX` is an UNANCHORED "contains" test over the marker's BEFORE-MARKER text
|
|
1398
|
+
// (`slice(clauseStart, i)`, a negator anywhere before the marker disqualifies it), so it must NOT be
|
|
1399
|
+
// capped to the bounded `clauseBeforeMarker` window — that window is sound only for the `$`-anchored
|
|
1400
|
+
// `PART_QUALIFIER_BEFORE_MARKER`, and capping the contains test to it silently dropped a negator
|
|
1401
|
+
// sitting more than `PREFIX_WINDOW` chars before the marker, failing the guard open (issue #858
|
|
1402
|
+
// round-12 adversarial review). The before-marker text is NOT identical across a clause's markers — it
|
|
1403
|
+
// GROWS with each marker's offset `i` — so a single boolean keyed by clause start let the first
|
|
1404
|
+
// (shortest-prefix) marker decide every later one, crediting a disclaimed trailing marker whose own
|
|
1405
|
+
// prefix DOES hold a negator (issue #858 round-14 review). Because the test is monotonic in `i`, cache
|
|
1406
|
+
// instead the earliest absolute offset at which a negator completes (one O(clause-length) scan per
|
|
1407
|
+
// clause, via `earliestNegatorEnd`); a marker at `i` is negated iff that offset `<= i`.
|
|
1408
|
+
const negatedPrefixEndByClause = new Map<number, number>();
|
|
1409
|
+
// The four assertion disqualifiers (`SCOPE_ATTRIBUTED_TO_OTHERS`, `SCOPE_ACTIVE_VOICE_OTHERS`,
|
|
1410
|
+
// `SCOPE_NEGATED_CORE`, `SCOPE_NEGATED_WITHOUT_DELIVERY`) are "contains" tests over the marker's
|
|
1411
|
+
// assertion segment. Occurrences that share a segment START sit in nested segments (same start, growing
|
|
1412
|
+
// end — e.g. a delimiter-free clause where every coordinator is a compound predicate), so each such test
|
|
1413
|
+
// is MONOTONIC in the segment end: a prefix that matches stays matching as it grows, and a prefix that
|
|
1414
|
+
// does NOT match means no shorter prefix in the group matches either. So, per absStart-group, test each
|
|
1415
|
+
// regex ONCE against the group's LONGEST segment: if it does not match, no occurrence in the group is
|
|
1416
|
+
// disqualified by it; if it does match, only the occurrences at/past the minimal matching end are
|
|
1417
|
+
// disqualified (found by a binary search over the group's sorted ends). This collapses the per-marker
|
|
1418
|
+
// regex work on a pathological clause from O(markers · segment) to O(segment) per group (issue #858
|
|
1419
|
+
// round-12 review).
|
|
1420
|
+
const ASSERTION_DISQUALIFIERS: ReadonlyArray<RegExp> = [
|
|
1421
|
+
SCOPE_ATTRIBUTED_TO_OTHERS,
|
|
1422
|
+
SCOPE_ACTIVE_VOICE_OTHERS,
|
|
1423
|
+
SCOPE_NEGATED_CORE,
|
|
1424
|
+
SCOPE_NEGATED_WITHOUT_DELIVERY,
|
|
1425
|
+
];
|
|
1426
|
+
|
|
1427
|
+
// First pass: compute each surviving occurrence's assertion segment and group occurrences by segment
|
|
1428
|
+
// start (occurrences are already in ascending position order, so each group's ends come out sorted).
|
|
1429
|
+
interface Occurrence {
|
|
1430
|
+
i: number;
|
|
1431
|
+
markerEnd: number;
|
|
1432
|
+
absStart: number;
|
|
1433
|
+
absEnd: number;
|
|
1434
|
+
text: string;
|
|
1435
|
+
}
|
|
1436
|
+
const groups = new Map<number, Occurrence[]>();
|
|
1437
|
+
for (const [i, markerEnd] of occurrences) {
|
|
1438
|
+
if (isPartQualified(prompt, markerEnd)) continue;
|
|
1439
|
+
if (isExclusionQualified(prompt, markerEnd)) continue;
|
|
1440
|
+
const assertion = deliveryAssertionAround(prompt, i, markerEnd, bounds, assertionCache);
|
|
1441
|
+
const occ: Occurrence = { i, markerEnd, absStart: assertion.absStart, absEnd: assertion.absEnd, text: assertion.text };
|
|
1442
|
+
const g = groups.get(assertion.absStart);
|
|
1443
|
+
if (g === undefined) groups.set(assertion.absStart, [occ]);
|
|
1444
|
+
else g.push(occ);
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
// Per group + per regex, the minimal segment-end that matches (Infinity if the longest segment is
|
|
1448
|
+
// clean). Computed once per group against the longest segment, refined by binary search only on a hit.
|
|
1449
|
+
const groupMinMatch = new Map<number, number[]>();
|
|
1450
|
+
for (const [absStart, group] of groups) {
|
|
1451
|
+
const longest = group[group.length - 1]; // ascending ends ⇒ last is longest
|
|
1452
|
+
const minMatch = ASSERTION_DISQUALIFIERS.map((re) => {
|
|
1453
|
+
if (!re.test(longest.text)) return Number.POSITIVE_INFINITY; // clean for the whole group
|
|
1454
|
+
// The regex matches somewhere in the longest segment. Find the minimal group-end that still matches
|
|
1455
|
+
// (monotonic ⇒ binary search over the group's sorted absEnd values).
|
|
1456
|
+
let lo = 0;
|
|
1457
|
+
let hi = group.length - 1; // invariant: group[hi] matches, group[lo-1] (virtual) does not
|
|
1458
|
+
while (lo < hi) {
|
|
1459
|
+
const mid = (lo + hi) >> 1;
|
|
1460
|
+
if (re.test(group[mid].text)) hi = mid;
|
|
1461
|
+
else lo = mid + 1;
|
|
1462
|
+
}
|
|
1463
|
+
return group[lo].absEnd;
|
|
1464
|
+
});
|
|
1465
|
+
groupMinMatch.set(absStart, minMatch);
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
for (const [, group] of groups) {
|
|
1469
|
+
const minMatch = groupMinMatch.get(group[0].absStart);
|
|
1470
|
+
for (const occ of group) {
|
|
1471
|
+
const { i, markerEnd, absEnd } = occ;
|
|
1472
|
+
// Disqualified iff ANY assertion disqualifier matches at a segment-end <= this occurrence's end.
|
|
1473
|
+
let disqualified = false;
|
|
1474
|
+
if (minMatch !== undefined) {
|
|
1475
|
+
for (const m of minMatch) {
|
|
1476
|
+
if (m <= absEnd) {
|
|
1477
|
+
disqualified = true;
|
|
1478
|
+
break;
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
if (disqualified) continue;
|
|
1483
|
+
// …but a negator AFTER the marker that REFERENCES DELIVERY (`…full scope of #12 AND it is not
|
|
1484
|
+
// fully delivered`) still disclaims the marker even though the `and`-coordinator splits it into a
|
|
1485
|
+
// sibling segment the assertion above never sees — re-catch it against the marker-relative suffix
|
|
1486
|
+
// (the text from the marker to the clause end) so the assertion-scoping does not fail open, without
|
|
1487
|
+
// matching a negator that PRECEDES the marker (issue #858 round-6 + round-13 adversarial reviews).
|
|
1488
|
+
const clauseStart = clauseStartAt(bounds, i);
|
|
1489
|
+
let negatedAfterMarker = negatedAfterMarkerByMarker.get(i);
|
|
1490
|
+
if (negatedAfterMarker === undefined) {
|
|
1491
|
+
negatedAfterMarker = SCOPE_NEGATED_AFTER_MARKER_DELIVERY.test(prompt.slice(i, clauseEndAt(bounds, i, prompt.length)));
|
|
1492
|
+
negatedAfterMarkerByMarker.set(i, negatedAfterMarker);
|
|
1493
|
+
}
|
|
1494
|
+
if (negatedAfterMarker) continue;
|
|
1495
|
+
// `SCOPE_NEGATED_PREFIX` is an unanchored "contains" test, so it must run against the WHOLE
|
|
1496
|
+
// before-marker text — not the bounded window, which would drop a negator sitting more than
|
|
1497
|
+
// `PREFIX_WINDOW` chars before the marker (issue #858 round-12 adversarial review). The earliest
|
|
1498
|
+
// negator offset is cached per clause and compared with THIS marker's own `i` (issue #858 round-14
|
|
1499
|
+
// review): a clause-wide boolean let the first marker's clean prefix credit a later disclaimed one.
|
|
1500
|
+
// Only the genuinely `$`-anchored `PART_QUALIFIER_BEFORE_MARKER` may use the window.
|
|
1501
|
+
let negatedPrefixEnd = negatedPrefixEndByClause.get(clauseStart);
|
|
1502
|
+
if (negatedPrefixEnd === undefined) {
|
|
1503
|
+
negatedPrefixEnd = earliestNegatorEnd(prompt, clauseStart, clauseEndAt(bounds, i, prompt.length));
|
|
1504
|
+
negatedPrefixEndByClause.set(clauseStart, negatedPrefixEnd);
|
|
1505
|
+
}
|
|
1506
|
+
if (negatedPrefixEnd <= i) continue;
|
|
1507
|
+
if (PART_QUALIFIER_BEFORE_MARKER.test(clauseBeforeMarker(prompt, i, bounds))) continue;
|
|
1508
|
+
const anchored = anchoredIssueKey(prompt, i, markerEnd);
|
|
1509
|
+
if (anchored !== null) acknowledged.add(anchored);
|
|
1510
|
+
else if (sole !== null) acknowledged.add(sole);
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
for (const n of numbered) {
|
|
1515
|
+
if (!acknowledged.has(n)) return true;
|
|
1516
|
+
}
|
|
1517
|
+
// A pronoun close (or a closing verb with no resolvable numbered target) can't name its issue, so
|
|
1518
|
+
// every referenced issue must be acknowledged for the close to be licensed.
|
|
1519
|
+
if (pronoun || numbered.length === 0) {
|
|
1520
|
+
for (const n of distinct) {
|
|
1521
|
+
if (!acknowledged.has(n)) return true;
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
return false;
|
|
1525
|
+
}
|
|
1526
|
+
|
|
270
1527
|
/** The graph's optional top-level `name` must match openapi's `DeliveryGraph.name` `maxLength: 255`.
|
|
271
1528
|
* Re-enforced here INDEPENDENTLY of the OpenAPI shape gate because later steps trust it: the compiler
|
|
272
1529
|
* feeds `graph.name` straight into `escapeXml(processName)` / `escapeMermaid`, so a NON-STRING name
|
|
@@ -784,6 +2041,25 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
|
|
|
784
2041
|
code: "invalid-node-base-branch",
|
|
785
2042
|
});
|
|
786
2043
|
}
|
|
2044
|
+
// Issue #858: the deterministic compile/lint-time guard for the partial-scope-close defect
|
|
2045
|
+
// class. An `agent` node's `prompt` is planner/authored free text; when it pairs a GitHub
|
|
2046
|
+
// closing keyword (`Closes/Fixes/Resolves #N`) with NO explicit full-scope acknowledgement
|
|
2047
|
+
// marker, it is the field-case defect (a brief scoped to PART of an issue told to close it).
|
|
2048
|
+
// Reject it path-qualified so the planner must either scope the brief to the issue's FULL
|
|
2049
|
+
// stated scope (and say so) or reference the issue non-blockingly (`Part of #N` / `Refs #N`).
|
|
2050
|
+
if (kind === "agent" && typeof config.prompt === "string" && isPartialScopeClose(config.prompt)) {
|
|
2051
|
+
errors.push({
|
|
2052
|
+
path: `${path}.${configKey}.prompt`,
|
|
2053
|
+
message:
|
|
2054
|
+
"`agent.prompt` closes an issue (`Closes/Fixes/Resolves #N`) but carries no full-scope " +
|
|
2055
|
+
"acknowledgement TIED to that issue — a brief scoped to PART of an issue must not be " +
|
|
2056
|
+
"told to close it (issue #858). Anchor the acknowledgement to the SAME issue you close " +
|
|
2057
|
+
"(e.g. \"delivers #N's full stated scope\" / \"every acceptance criterion of #N\"); a " +
|
|
2058
|
+
"marker for a different issue, or one attributed to siblings, does not count. Otherwise " +
|
|
2059
|
+
"reference the issue non-blockingly (`Part of #N` / `Refs #N`) and leave it open.",
|
|
2060
|
+
code: "partial-scope-close",
|
|
2061
|
+
});
|
|
2062
|
+
}
|
|
787
2063
|
// #548: register a converge-connector / pr-wait as a PR-binding consumer (pass 4 validates the
|
|
788
2064
|
// binding once edges are resolved). Only when the id is usable so pass 4 can key by node id.
|
|
789
2065
|
if (typeof id === "string" && id.length > 0) {
|