@dev-loops/core 1.0.0-rc.6 → 1.0.0-rc.7

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.
Files changed (41) hide show
  1. package/package.json +7 -1
  2. package/src/analysis/change-classifier.mjs +10 -0
  3. package/src/analysis/diff-analyzer.mjs +68 -1
  4. package/src/claude/hook-decisions.mjs +36 -4
  5. package/src/cli/primitives.mjs +30 -1
  6. package/src/config/config.mjs +254 -13
  7. package/src/config/extension-defaults.yaml +34 -1
  8. package/src/github/comment-id-guard.mjs +97 -9
  9. package/src/github/copilot-helpers.mjs +114 -5
  10. package/src/github/gh.mjs +94 -0
  11. package/src/github/issue-ops.mjs +7 -0
  12. package/src/loop/agent-stall.mjs +4 -2
  13. package/src/loop/copilot-loop-iterations.mjs +2 -1
  14. package/src/loop/default-branch-guard.mjs +34 -1
  15. package/src/loop/gate-carry-forward.mjs +19 -6
  16. package/src/loop/gate-fanin.mjs +190 -29
  17. package/src/loop/handoff-envelope.mjs +12 -19
  18. package/src/loop/lifecycle-state.mjs +21 -2
  19. package/src/loop/main-checkout-ff.mjs +34 -0
  20. package/src/loop/markdown-sections.mjs +40 -0
  21. package/src/loop/normalize.mjs +7 -0
  22. package/src/loop/plan-file-promote-contract.mjs +14 -1
  23. package/src/loop/plan-file-refine-contract.mjs +92 -8
  24. package/src/loop/policy-constants.mjs +9 -0
  25. package/src/loop/pr-gate-coordination.mjs +65 -12
  26. package/src/loop/public-dev-loop-routing.mjs +7 -15
  27. package/src/loop/queue-board-sync.mjs +1 -26
  28. package/src/loop/queue-driver.mjs +14 -1
  29. package/src/loop/refinement-grill-state.mjs +3 -5
  30. package/src/loop/review-dispatch-plan.mjs +448 -9
  31. package/src/loop/reviewer-loop-state.mjs +8 -13
  32. package/src/loop/run-post-merge-actions.mjs +148 -0
  33. package/src/loop/size-budget-merge-gate.mjs +121 -0
  34. package/src/loop/tracker-pr-state.mjs +5 -15
  35. package/src/loop/ui-designer-review-scoping.mjs +171 -0
  36. package/src/loop/ui-review-drive.mjs +3 -1
  37. package/src/loop/ui-review-report.mjs +2 -5
  38. package/src/loop/ui-review-teardown.mjs +3 -1
  39. package/src/projects/list-queue-items.mjs +1 -27
  40. package/src/projects/move-queue-item.mjs +1 -27
  41. package/src/security/secret-scan.mjs +330 -0
@@ -22,7 +22,10 @@
22
22
  * briefing block and before the late volatile tail + angle suffix.
23
23
  * 4. Dispatch-plan builder — one deterministic per-gate-round artifact that
24
24
  * records the complete cache-relevant request shape without duplicating
25
- * briefing content (Section A).
25
+ * briefing content (Section A). `buildAngleRequestGroups` partitions a
26
+ * caller's angle -> concrete-model resolutions into that plan's
27
+ * `requestGroups` shape, bucketing angles with no override into an
28
+ * explicit "inherit" key rather than merging them into a concrete group.
26
29
  * 5. Primer-form default — deterministic default by harness capability
27
30
  * (Section C/D): first-output-observable harnesses may let a lead reviewer
28
31
  * prime; completion-only harnesses default to a short dedicated primer
@@ -187,20 +190,51 @@ export function sha256Hex(content) {
187
190
  return `sha256:${h.digest("hex")}`;
188
191
  }
189
192
 
190
- const isPlainObject = (v) =>
191
- v != null && typeof v === "object" && !Array.isArray(v) && !Buffer.isBuffer(v);
193
+ /** True for a non-null value whose prototype is exactly Object.prototype or null (a JSON-shaped record, never a Date/Map/Set/class instance). */
194
+ function isPlainObject(value) {
195
+ if (value === null || typeof value !== "object") return false;
196
+ const proto = Object.getPrototypeOf(value);
197
+ return proto === Object.prototype || proto === null;
198
+ }
192
199
 
193
- /** Recursively sort object keys for a byte-deterministic serialization. */
194
- function stableStringify(value) {
195
- if (Array.isArray(value)) return value.map(stableStringify);
200
+ /**
201
+ * Recursively sort object keys for a byte-deterministic serialization (arrays
202
+ * keep their order — order is itself cache-relevant for tool definitions and
203
+ * content-block boundaries).
204
+ *
205
+ * Trust-boundary validation, refusing loudly rather than silently colliding
206
+ * two distinct inputs onto one fingerprint: a non-finite number (NaN/
207
+ * Infinity) is rejected rather than let `JSON.stringify` collapse it to
208
+ * `null`, a non-plain object (Date/Map/Set/...) is rejected rather than let
209
+ * `Object.keys` see it as keyless (and therefore indistinguishable from
210
+ * `{}`), and undefined/function/symbol/bigint are rejected rather than
211
+ * silently dropped or crash-serialized by `JSON.stringify` itself. The
212
+ * accumulator is null-prototype so an own `__proto__` key (a realistic shape
213
+ * for JSON.parse'd input) is kept as a plain data property instead of
214
+ * vanishing into the prototype chain.
215
+ * @param {*} value
216
+ * @param {string} [keyPath] — dotted path to `value`, for the error message
217
+ * @returns {*}
218
+ */
219
+ function stableStringify(value, keyPath = "$") {
220
+ if (Array.isArray(value)) return value.map((entry, i) => stableStringify(entry, `${keyPath}[${i}]`));
196
221
  // Canonicalize nested Buffers to hex (the `__buffer:` prefix keeps a Buffer
197
222
  // distinct from a string that happens to equal its hex, so bytes and text can
198
223
  // never collide). Mirrors sha256Hex's top-level Buffer handling so a push/pull
199
224
  // through memory vs disk yields identical bytes even for nested buffers.
200
225
  if (Buffer.isBuffer(value)) return `__buffer:${value.toString("hex")}`;
201
- if (isPlainObject(value)) {
202
- const out = {};
203
- for (const key of Object.keys(value).sort()) out[key] = stableStringify(value[key]);
226
+ if (typeof value === "number" && !Number.isFinite(value)) {
227
+ throw new Error(`sha256Hex: fingerprint input at ${keyPath} is a non-finite number (${value}) — refusing to collapse it to JSON null`);
228
+ }
229
+ if (value === undefined || typeof value === "function" || typeof value === "symbol" || typeof value === "bigint") {
230
+ throw new Error(`sha256Hex: fingerprint input at ${keyPath} is a ${typeof value} — refusing to silently drop or crash-serialize it`);
231
+ }
232
+ if (value !== null && typeof value === "object") {
233
+ if (!isPlainObject(value)) {
234
+ throw new Error(`sha256Hex: fingerprint input at ${keyPath} is not a plain object (got ${Object.prototype.toString.call(value)}) — refusing to canonicalize a keyless collision`);
235
+ }
236
+ const out = Object.create(null);
237
+ for (const key of Object.keys(value).sort()) out[key] = stableStringify(value[key], `${keyPath}.${key}`);
204
238
  return out;
205
239
  }
206
240
  return value;
@@ -491,6 +525,107 @@ function validateRequestGroups(requestGroups) {
491
525
  });
492
526
  }
493
527
 
528
+ /* ------------------------------------------------------------------ *
529
+ * 4b. Angle model bucketing (per-caller angle -> concrete model resolution)
530
+ * ------------------------------------------------------------------ */
531
+
532
+ /**
533
+ * `requestGroups` bucket key for angles whose model resolution is "no
534
+ * override" (inherit). Reserved: a caller-resolved concrete model literally
535
+ * named this would otherwise collide with the "no override" bucket and
536
+ * become indistinguishable from genuine inherit.
537
+ */
538
+ export const INHERIT_MODEL_KEY = "inherit";
539
+
540
+ /**
541
+ * Partition a caller's angle -> concrete-model resolutions into fingerprinted
542
+ * request groups ready for {@link buildReviewDispatchPlan}'s `requestGroups`
543
+ * input. Angles resolving to the same concrete model id share one group;
544
+ * angles with no override (`model: null`/`undefined`) form their own explicit
545
+ * {@link INHERIT_MODEL_KEY} bucket, never merged with a concrete id. An angle
546
+ * listed twice with two DIFFERENT models is a caller bug and throws (an angle
547
+ * cannot honestly belong to two request groups). A concrete model literally
548
+ * named {@link INHERIT_MODEL_KEY} also throws — it would otherwise silently
549
+ * collide with the reserved bucket key and become indistinguishable from
550
+ * genuine no-override.
551
+ *
552
+ * Each group's `requestPrefixFingerprint` is computed via
553
+ * {@link fingerprintRequestPrefix} over every cache-relevant input this layer
554
+ * observes for that group (the bucket's model, tool set/order, instructions,
555
+ * settings, content-block boundaries, the shared-prefix bytes, and the
556
+ * declared cache boundary/TTL intent) — changing only the angle set within a
557
+ * bucket never changes its fingerprint.
558
+ *
559
+ * @param {object} input
560
+ * @param {Array<{angle: string, model: string|null}>} input.angleModels
561
+ * @param {string} [input.sharedPrefixHash] — folded in as the fingerprint's shared-artifact reference.
562
+ * @param {Array<string|object>} [input.toolDefinitions] — tool names/definitions in dispatch order
563
+ * @param {string|string[]} [input.instructions] — system/project/agent instruction bytes (or a digest)
564
+ * @param {object} [input.settings] — thinking/tool-choice settings
565
+ * @param {string[]} [input.blockBoundaries] — content-block boundary markers, in order
566
+ * @param {string} [input.cacheBoundary]
567
+ * @param {string} [input.ttlIntent] — one of TTL_INTENT_VALUES
568
+ * @returns {RequestGroup[]} sorted by model (code-unit order, never localeCompare — ICU-dependent
569
+ * sorting could order the same two model ids differently across runtimes); angles sorted within a group.
570
+ */
571
+ export function buildAngleRequestGroups({
572
+ angleModels,
573
+ sharedPrefixHash,
574
+ toolDefinitions = [],
575
+ instructions = "",
576
+ settings = {},
577
+ blockBoundaries = [],
578
+ cacheBoundary = CACHE_BOUNDARY_AFTER_SHARED_PREFIX,
579
+ ttlIntent = "harness_managed",
580
+ } = {}) {
581
+ if (!Array.isArray(angleModels)) {
582
+ throw new Error("buildAngleRequestGroups: angleModels must be an array of { angle, model }");
583
+ }
584
+
585
+ const angleToModelKey = new Map();
586
+ const anglesByModelKey = new Map();
587
+ for (const entry of angleModels) {
588
+ const angle = typeof entry?.angle === "string" ? entry.angle.trim() : "";
589
+ if (angle.length === 0) {
590
+ throw new Error("buildAngleRequestGroups: every angleModels entry needs a non-empty string angle");
591
+ }
592
+ const rawModel = entry.model;
593
+ if (rawModel != null && (typeof rawModel !== "string" || rawModel.trim().length === 0)) {
594
+ throw new Error(`buildAngleRequestGroups: angleModels entry for "${angle}" has an invalid model (must be a non-empty string, or null/undefined for inherit)`);
595
+ }
596
+ const modelKey = rawModel == null ? INHERIT_MODEL_KEY : rawModel.trim();
597
+ if (rawModel != null && modelKey === INHERIT_MODEL_KEY) {
598
+ throw new Error(`buildAngleRequestGroups: angle "${angle}" has a concrete model literally named ${JSON.stringify(INHERIT_MODEL_KEY)}, which collides with the bucket key reserved for "no override" — rename the model, or resolve it to null/undefined instead of the literal string`);
599
+ }
600
+
601
+ const priorKey = angleToModelKey.get(angle);
602
+ if (priorKey !== undefined && priorKey !== modelKey) {
603
+ throw new Error(`buildAngleRequestGroups: angle "${angle}" is listed with two different models ("${priorKey}" and "${modelKey}") — an angle cannot belong to two request groups`);
604
+ }
605
+ angleToModelKey.set(angle, modelKey);
606
+
607
+ if (!anglesByModelKey.has(modelKey)) anglesByModelKey.set(modelKey, new Set());
608
+ anglesByModelKey.get(modelKey).add(angle);
609
+ }
610
+
611
+ return [...anglesByModelKey.entries()]
612
+ .map(([model, angleSet]) => {
613
+ const angles = [...angleSet].sort();
614
+ const { fingerprint } = fingerprintRequestPrefix({
615
+ model,
616
+ tools: toolDefinitions,
617
+ systemInstructions: instructions,
618
+ settings,
619
+ contentBlocks: blockBoundaries,
620
+ sharedArtifact: sharedPrefixHash,
621
+ cacheBoundary,
622
+ ttlIntent,
623
+ });
624
+ return { model, requestPrefixFingerprint: fingerprint, cacheBoundary, ttlIntent, angles };
625
+ })
626
+ .sort((a, b) => (a.model < b.model ? -1 : a.model > b.model ? 1 : 0));
627
+ }
628
+
494
629
  /* ------------------------------------------------------------------ *
495
630
  * 5. Primer-form default (Section C/D)
496
631
  * ------------------------------------------------------------------ */
@@ -593,3 +728,307 @@ export function partitionPrimerGroups(requestGroups, capabilities = {}) {
593
728
  }
594
729
  return out;
595
730
  }
731
+
732
+ /* ------------------------------------------------------------------ *
733
+ * 6. Dispatch-prompt layout alignment (issue #1841, completes #1468)
734
+ * ------------------------------------------------------------------ */
735
+
736
+ // Leading-bytes capture cap for a dispatched reviewer prompt (issue #1841's
737
+ // record-dispatch-prompt-layout.mjs). Sized comfortably above
738
+ // write-gate-context.mjs's BRIEFING_PREFIX_INLINE_DIFF_CAP_BYTES (200 KiB) so
739
+ // a full byte-for-byte alignment check never runs out of captured bytes for
740
+ // an inline-mode round.
741
+ export const DISPATCH_PROMPT_LEADING_CAP_BYTES = 512 * 1024;
742
+
743
+ /**
744
+ * Render the byte-identical pointer LINE a reviewer prompt must lead with
745
+ * under pointer-seeding mode (GATE-EXEC-BRIEFING-PREFIX's "Cache alignment"
746
+ * paragraph): the orchestrator points every reviewer of the round at the SAME
747
+ * invariant-prefix file path rather than inlining its bytes. Deterministic in
748
+ * `prefixPath` alone, so two reviewers given the same path render the
749
+ * identical line, and a per-reviewer/angle-varying path (which would defeat
750
+ * prefix matching) renders a DIFFERENT line, exactly reproducing the defect
751
+ * this line exists to catch.
752
+ *
753
+ * @param {string} prefixPath — the invariant-prefix file path every reviewer
754
+ * of the round is pointed at (e.g. the `<gate>-<headSha>.briefing-prefix.txt`
755
+ * path).
756
+ * @returns {string}
757
+ */
758
+ export function renderBriefingPointerLine(prefixPath) {
759
+ if (typeof prefixPath !== "string" || prefixPath.trim().length === 0) {
760
+ throw new Error("renderBriefingPointerLine requires a non-empty prefixPath");
761
+ }
762
+ return `Read ${prefixPath.trim()} FIRST, in full, before anything else in this prompt — it is this round's byte-identical invariant briefing prefix (GATE-EXEC-BRIEFING-PREFIX). Your angle-specific instructions follow below, after it.`;
763
+ }
764
+
765
+ /**
766
+ * Deterministically compose a full reviewer prompt: the round's
767
+ * byte-identical invariant prefix INLINED as the leading bytes, followed by
768
+ * the (also round-invariant) volatile tail, followed by the per-group angle
769
+ * suffix (issue #1852). This is the ONE function every reviewer prompt on the
770
+ * canonical fan-out path is built from — never a hand-assembled per-group
771
+ * preamble that leads with dynamic prose ahead of the prefix (the
772
+ * "angle-first" / pointer-seeding failure mode `verifyPromptLeadingAlignment`
773
+ * exists to catch).
774
+ *
775
+ * Byte-identical-prefix-across-groups falls out of the arguments alone: any
776
+ * two calls sharing the same `prefixBytes`/`volatileBytes` (true for every
777
+ * dispatch unit of one round, since both are round-scoped, not group-scoped)
778
+ * produce prompts whose leading span is identical regardless of
779
+ * `angleSuffix` — the property AC1 requires, provable by construction rather
780
+ * than by review.
781
+ *
782
+ * Pure and offline: takes already-read bytes, never reads a file itself (the
783
+ * CLI wrapper, `compose-reviewer-prompt.mjs`, owns I/O and the
784
+ * record-dispatch-prompt-layout.mjs capture that makes the composed prompt's
785
+ * layout binding on `verify-dispatch-prompt-layout.mjs`).
786
+ *
787
+ * @param {object} input
788
+ * @param {string} input.prefixBytes — the round's invariant-prefix bytes
789
+ * (`<gate>-<headSha>.briefing-prefix.txt`), non-empty.
790
+ * @param {string} [input.volatileBytes] — the round's volatile-tail bytes
791
+ * (`<gate>-<headSha>.briefing-volatile.txt`); absent/non-string treated as
792
+ * "" (best-effort — a round that never wrote one still composes).
793
+ * @param {string} input.angleSuffix — the per-group/angle-specific prompt
794
+ * text, non-empty (an empty suffix would compose a prompt naming no work).
795
+ * @returns {string} the exact full reviewer prompt text.
796
+ */
797
+ export function composeReviewerPromptText({ prefixBytes, volatileBytes, angleSuffix } = {}) {
798
+ if (typeof prefixBytes !== "string" || prefixBytes.length === 0) {
799
+ throw new Error("composeReviewerPromptText requires non-empty prefixBytes (the round's invariant prefix)");
800
+ }
801
+ if (typeof angleSuffix !== "string" || angleSuffix.trim().length === 0) {
802
+ throw new Error("composeReviewerPromptText requires a non-empty angleSuffix (the per-group angle-specific prompt)");
803
+ }
804
+ const volatile = typeof volatileBytes === "string" ? volatileBytes : "";
805
+ return prefixBytes + volatile + angleSuffix;
806
+ }
807
+
808
+ /**
809
+ * Decide whether a dispatched reviewer prompt's LEADING bytes are
810
+ * cache-aligned (GATE-EXEC-BRIEFING-PREFIX layout, issue #1841): either the
811
+ * prompt's leading bytes are byte-identical to the round's invariant prefix
812
+ * (inline mode), or the prompt leads with the byte-identical pointer line
813
+ * naming the round's invariant-prefix path (pointer-seeding mode), with any
814
+ * angle-specific text strictly AFTER it. An angle-first prompt (dynamic
815
+ * per-unit prose ahead of the prefix/pointer) matches neither and is
816
+ * REJECTED — this is the mechanical proof the prose-only rule lacked.
817
+ *
818
+ * Pure and offline: takes the already-captured leading bytes and the already-
819
+ * read prefix bytes/path, never reads a file itself (the CLI wrapper owns
820
+ * I/O), so this is directly unit-testable with in-memory strings.
821
+ *
822
+ * @param {object} input
823
+ * @param {string} input.promptLeading — the captured leading bytes of the
824
+ * ACTUAL reviewer prompt (record-dispatch-prompt-layout.mjs's capture).
825
+ * @param {string} input.prefixBytes — the round's recorded byte-identical
826
+ * invariant-prefix content (the `<gate>-<headSha>.briefing-prefix.txt`
827
+ * bytes).
828
+ * @param {string} input.prefixPath — the path used to render this round's
829
+ * pointer line (must be the SAME path every reviewer was pointed at).
830
+ * @returns {{ aligned: boolean, mode: "inline"|"pointer"|null, reason: string|null }}
831
+ */
832
+ export function verifyPromptLeadingAlignment({ promptLeading, prefixBytes, prefixPath } = {}) {
833
+ const leading = typeof promptLeading === "string" ? promptLeading : "";
834
+ if (typeof prefixBytes === "string" && prefixBytes.length > 0 && leading.startsWith(prefixBytes)) {
835
+ return { aligned: true, mode: "inline", reason: null };
836
+ }
837
+ if (typeof prefixPath === "string" && prefixPath.trim().length > 0) {
838
+ const pointerLine = renderBriefingPointerLine(prefixPath);
839
+ if (leading.startsWith(pointerLine)) {
840
+ return { aligned: true, mode: "pointer", reason: null };
841
+ }
842
+ }
843
+ return {
844
+ aligned: false,
845
+ mode: null,
846
+ reason: "reviewer prompt does not LEAD with the round's byte-identical invariant prefix (inline mode) or its byte-identical pointer line (pointer-seeding mode) — an angle-first prompt (dynamic per-unit prose ahead of the prefix/pointer) defeats prefix matching (GATE-EXEC-BRIEFING-PREFIX)",
847
+ };
848
+ }
849
+
850
+ /* ------------------------------------------------------------------ *
851
+ * 7. Diff filtering for the shared per-head block (issue #1853)
852
+ * ------------------------------------------------------------------ */
853
+
854
+ /**
855
+ * Default excluded path patterns for the diff INLINED into a reviewer
856
+ * prompt's shared per-head block: lockfiles (high-churn, not
857
+ * review-relevant — the file's CHANGE is still listed in the changed-files
858
+ * summary, only its hunk text is dropped from the inlined diff) and common
859
+ * generated/vendored trees. ALWAYS applied on top of any caller-supplied
860
+ * `excludeGlobs` in {@link filterDiffForInline} — never replaced by it, so a
861
+ * project-specific config gap can't silently un-exclude a lockfile. A file
862
+ * excluded here is not deleted from the repo or the diff on disk; it stays
863
+ * readable on demand (`git diff -- <path>` in the reviewed worktree, or the
864
+ * full unfiltered `.diff` pointer file), only not inlined by default.
865
+ *
866
+ * Glob subset: `**\/` matches zero-or-more whole path segments, a lone `**`
867
+ * matches any suffix, a single `*` matches within one path segment only —
868
+ * see {@link matchesDiffExcludeGlob}.
869
+ */
870
+ export const DEFAULT_DIFF_EXCLUDE_GLOBS = Object.freeze([
871
+ // Lockfiles.
872
+ "package-lock.json", "**/package-lock.json",
873
+ "npm-shrinkwrap.json", "**/npm-shrinkwrap.json",
874
+ "yarn.lock", "**/yarn.lock",
875
+ "pnpm-lock.yaml", "**/pnpm-lock.yaml",
876
+ "*-lock.yaml", "**/*-lock.yaml",
877
+ "*-lock.yml", "**/*-lock.yml",
878
+ "Cargo.lock", "**/Cargo.lock",
879
+ "Gemfile.lock", "**/Gemfile.lock",
880
+ "composer.lock", "**/composer.lock",
881
+ // Generated/vendored trees.
882
+ "dist/**", "**/dist/**",
883
+ "lib/**", "**/lib/**",
884
+ "coverage/**", "**/coverage/**",
885
+ "node_modules/**", "**/node_modules/**",
886
+ ".claude/**", "**/.claude/**",
887
+ ]);
888
+
889
+ function escapeDiffGlobLiteral(ch) {
890
+ return /[.*+?^${}()|[\]\\]/.test(ch) ? `\\${ch}` : ch;
891
+ }
892
+
893
+ const diffGlobPatternCache = new Map();
894
+
895
+ /**
896
+ * Minimal shell-glob subset compiler (mirrors the established pattern used
897
+ * elsewhere in this repo for config-driven path patterns): `**\/` matches
898
+ * zero-or-more whole path segments, a lone `**` matches any suffix
899
+ * (including `/`), a single `*` matches within one path segment only,
900
+ * everything else is literal. No glob dependency is installed in this repo
901
+ * and none of the callers need more than this subset.
902
+ * @param {string} relPath — POSIX-normalized repo-relative path
903
+ * @param {string} pattern
904
+ * @returns {boolean}
905
+ */
906
+ export function matchesDiffExcludeGlob(relPath, pattern) {
907
+ if (typeof relPath !== "string" || typeof pattern !== "string" || pattern.length === 0) return false;
908
+ let compiled = diffGlobPatternCache.get(pattern);
909
+ if (!compiled) {
910
+ let re = "";
911
+ for (let i = 0; i < pattern.length; i++) {
912
+ const ch = pattern[i];
913
+ if (ch === "*" && pattern[i + 1] === "*") {
914
+ if (pattern[i + 2] === "/") {
915
+ re += "(?:.*/)?";
916
+ i += 2;
917
+ } else {
918
+ re += ".*";
919
+ i += 1;
920
+ }
921
+ } else if (ch === "*") {
922
+ re += "[^/]*";
923
+ } else {
924
+ re += escapeDiffGlobLiteral(ch);
925
+ }
926
+ }
927
+ compiled = new RegExp(`^${re}$`);
928
+ diffGlobPatternCache.set(pattern, compiled);
929
+ }
930
+ return compiled.test(relPath);
931
+ }
932
+
933
+ /**
934
+ * Classify why a diff file is excluded from inlining, or `null` when it
935
+ * should be inlined. Checks {@link DEFAULT_DIFF_EXCLUDE_GLOBS} first, then
936
+ * any caller-supplied `excludeGlobs` — the default set can never be
937
+ * disabled by a caller's config.
938
+ * @param {string} relPath
939
+ * @param {{ excludeGlobs?: string[] }} [opts]
940
+ * @returns {"default"|"configured"|null}
941
+ */
942
+ export function classifyDiffFileExclusion(relPath, { excludeGlobs = [] } = {}) {
943
+ const posix = String(relPath).replace(/\\/g, "/");
944
+ for (const pattern of DEFAULT_DIFF_EXCLUDE_GLOBS) {
945
+ if (matchesDiffExcludeGlob(posix, pattern)) return "default";
946
+ }
947
+ for (const pattern of excludeGlobs) {
948
+ if (matchesDiffExcludeGlob(posix, pattern)) return "configured";
949
+ }
950
+ return null;
951
+ }
952
+
953
+ /**
954
+ * Extract a diff file-block's resulting path. Prefers the `+++ b/<path>`
955
+ * line (present for every non-deletion block), falls back to `--- a/<path>`
956
+ * (deletions), then to the `diff --git a/X b/Y` header's second token. This
957
+ * is a best-effort extraction for FILTERING purposes only (unlike a
958
+ * content-fidelity transform, a path this misses just fails open to
959
+ * "inlined" — never mis-drops a file), so it does not attempt full
960
+ * git-quoted-path decoding (rare: a path containing a quote/control
961
+ * byte/non-ASCII byte under core.quotePath) — see
962
+ * `scripts/github/write-gate-context.mjs`'s `decodeGitDiffPathToken` for
963
+ * that fuller decode if this ever needs it.
964
+ * @param {string[]} blockLines
965
+ * @returns {string|null}
966
+ */
967
+ function extractDiffBlockPath(blockLines) {
968
+ for (const line of blockLines) {
969
+ if (line.startsWith("+++ ") && !line.includes("/dev/null")) {
970
+ return line.slice(4).trim().replace(/^[abiwco]\//, "");
971
+ }
972
+ }
973
+ for (const line of blockLines) {
974
+ if (line.startsWith("--- ") && !line.includes("/dev/null")) {
975
+ return line.slice(4).trim().replace(/^[abiwco]\//, "");
976
+ }
977
+ }
978
+ const header = blockLines[0] ?? "";
979
+ const m = /^diff --git \S+ (\S+)$/.exec(header);
980
+ return m ? m[1].replace(/^[abiwco]\//, "") : null;
981
+ }
982
+
983
+ /**
984
+ * Filter a unified diff (`git diff` output) down to the files that should be
985
+ * INLINED into a reviewer prompt's shared per-head block (issue #1853):
986
+ * lockfiles, generated/vendored trees, and any caller-configured
987
+ * `excludeGlobs` are dropped whole-file (header + all hunks), every other
988
+ * file's block passes through byte-for-byte unchanged. Excluding a file here
989
+ * only affects what this function returns — it never touches the diff on
990
+ * disk or in the reviewed worktree, so an excluded file stays readable on
991
+ * demand.
992
+ *
993
+ * Pure and offline: string in, string out, no I/O — the caller (currently
994
+ * `write-gate-context.mjs`, before rendering the invariant prefix) supplies
995
+ * already-captured diff text.
996
+ *
997
+ * @param {string} diffText — `git diff` output (or `""`/absent).
998
+ * @param {{ excludeGlobs?: string[] }} [opts] — additional exclude globs,
999
+ * layered on top of {@link DEFAULT_DIFF_EXCLUDE_GLOBS} (never replacing it).
1000
+ * @returns {{ filteredDiff: string, excludedFiles: Array<{path: string, reason: "default"|"configured"}>, includedFiles: string[] }}
1001
+ */
1002
+ export function filterDiffForInline(diffText, { excludeGlobs = [] } = {}) {
1003
+ if (typeof diffText !== "string" || diffText.length === 0) {
1004
+ return { filteredDiff: typeof diffText === "string" ? diffText : "", excludedFiles: [], includedFiles: [] };
1005
+ }
1006
+ const lines = diffText.split("\n");
1007
+ const blockStarts = [];
1008
+ for (let i = 0; i < lines.length; i++) {
1009
+ if (lines[i].startsWith("diff --git ")) blockStarts.push(i);
1010
+ }
1011
+ // No recognizable `diff --git` file boundary — not a shape this filter
1012
+ // understands; pass through unfiltered rather than guess.
1013
+ if (blockStarts.length === 0) {
1014
+ return { filteredDiff: diffText, excludedFiles: [], includedFiles: [] };
1015
+ }
1016
+ const keptChunks = [];
1017
+ const excludedFiles = [];
1018
+ const includedFiles = [];
1019
+ if (blockStarts[0] > 0) keptChunks.push(lines.slice(0, blockStarts[0]).join("\n"));
1020
+ for (let b = 0; b < blockStarts.length; b++) {
1021
+ const start = blockStarts[b];
1022
+ const end = b + 1 < blockStarts.length ? blockStarts[b + 1] : lines.length;
1023
+ const blockLines = lines.slice(start, end);
1024
+ const relPath = extractDiffBlockPath(blockLines);
1025
+ const reason = relPath ? classifyDiffFileExclusion(relPath, { excludeGlobs }) : null;
1026
+ if (reason) {
1027
+ excludedFiles.push({ path: relPath, reason });
1028
+ } else {
1029
+ if (relPath) includedFiles.push(relPath);
1030
+ keptChunks.push(blockLines.join("\n"));
1031
+ }
1032
+ }
1033
+ return { filteredDiff: keptChunks.join("\n"), excludedFiles, includedFiles };
1034
+ }
@@ -2,6 +2,7 @@
2
2
  * Deterministic state machine and bounded planning/merge contracts for reviewer-side PR loops.
3
3
  */
4
4
  import { SUBMITTED_REVIEW_STATES } from "../github/copilot-helpers.mjs";
5
+ import { trimmedOrNull } from "./normalize.mjs";
5
6
 
6
7
  export const REVIEWER_STATE = Object.freeze({
7
8
  WAITING_FOR_REVIEW_REQUEST: "waiting_for_review_request",
@@ -117,10 +118,6 @@ const SUPPORTED_REVIEW_ANGLES = Object.freeze([
117
118
  const DEFAULT_REVIEW_MAX_PARALLEL = 3;
118
119
  const HARD_REVIEW_MAX_PARALLEL = 4;
119
120
 
120
- function normalizeSha(value) {
121
- return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
122
- }
123
-
124
121
  function normalizePositiveInt(value) {
125
122
  return typeof value === "number" && Number.isFinite(value) && value > 0
126
123
  ? Math.floor(value)
@@ -167,7 +164,7 @@ export function normalizeReviewerSnapshot(raw) {
167
164
  prDraft: Boolean(raw.prDraft),
168
165
  prMerged: Boolean(raw.prMerged),
169
166
  prClosed: Boolean(raw.prClosed),
170
- prHeadSha: prExists ? normalizeSha(raw.prHeadSha) : null,
167
+ prHeadSha: prExists ? trimmedOrNull(raw.prHeadSha) : null,
171
168
 
172
169
  reviewerScope,
173
170
  reviewerLogin: reviewerScope === "single_reviewer" ? reviewerLogin : null,
@@ -180,10 +177,8 @@ export function normalizeReviewerSnapshot(raw) {
180
177
 
181
178
  draftReviewPosted: Boolean(raw.draftReviewPosted),
182
179
  draftReviewId: normalizePositiveInt(raw.draftReviewId),
183
- draftReviewUrl: typeof raw.draftReviewUrl === "string" && raw.draftReviewUrl.trim().length > 0
184
- ? raw.draftReviewUrl.trim()
185
- : null,
186
- draftReviewCommitSha: normalizeSha(raw.draftReviewCommitSha),
180
+ draftReviewUrl: trimmedOrNull(raw.draftReviewUrl),
181
+ draftReviewCommitSha: trimmedOrNull(raw.draftReviewCommitSha),
187
182
  draftReviewNotificationStatus: normalizeStatus(
188
183
  raw.draftReviewNotificationStatus,
189
184
  VALID_DRAFT_NOTIFICATION_STATUSES,
@@ -191,7 +186,7 @@ export function normalizeReviewerSnapshot(raw) {
191
186
  ),
192
187
 
193
188
  submittedReviewPresent: Boolean(raw.submittedReviewPresent),
194
- submittedReviewCommitSha: normalizeSha(raw.submittedReviewCommitSha),
189
+ submittedReviewCommitSha: trimmedOrNull(raw.submittedReviewCommitSha),
195
190
  submittedReviewState: normalizeSubmittedReviewState(raw.submittedReviewState),
196
191
  reviewSubmissionStatus: normalizeStatus(raw.reviewSubmissionStatus, VALID_SUBMISSION_STATUSES, "none"),
197
192
  };
@@ -343,7 +338,7 @@ function findingDedupKey(finding) {
343
338
  * @returns {{headSha:string|null, verdict:string, inlineComments:object[], summaryFindings:object[], totalFindings:number, runsMerged:number}}
344
339
  */
345
340
  export function mergeReviewerResults(input = {}) {
346
- const headSha = normalizeSha(input.headSha);
341
+ const headSha = trimmedOrNull(input.headSha);
347
342
  const runResults = Array.isArray(input.runResults) ? input.runResults : [];
348
343
 
349
344
  const deduped = [];
@@ -388,7 +383,7 @@ export function mergeReviewerResults(input = {}) {
388
383
  }
389
384
 
390
385
  export function buildDraftReviewPayload(mergedResult = {}) {
391
- const headSha = normalizeSha(mergedResult.headSha);
386
+ const headSha = trimmedOrNull(mergedResult.headSha);
392
387
  const verdict = normalizeDraftVerdict(mergedResult.verdict);
393
388
  const inlineComments = Array.isArray(mergedResult.inlineComments) ? mergedResult.inlineComments : [];
394
389
  const summaryFindings = Array.isArray(mergedResult.summaryFindings) ? mergedResult.summaryFindings : [];
@@ -396,7 +391,7 @@ export function buildDraftReviewPayload(mergedResult = {}) {
396
391
  const comments = inlineComments
397
392
  .filter((finding) => finding && typeof finding === "object")
398
393
  .map((finding) => ({
399
- path: typeof finding.path === "string" && finding.path.trim().length > 0 ? finding.path.trim() : null,
394
+ path: trimmedOrNull(finding.path),
400
395
  line: typeof finding.line === "number" && finding.line > 0 ? Math.floor(finding.line) : null,
401
396
  body: typeof finding.message === "string" ? finding.message.trim() : "",
402
397
  side: "RIGHT",