@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.
- package/package.json +7 -1
- package/src/analysis/change-classifier.mjs +10 -0
- package/src/analysis/diff-analyzer.mjs +68 -1
- package/src/claude/hook-decisions.mjs +36 -4
- package/src/cli/primitives.mjs +30 -1
- package/src/config/config.mjs +254 -13
- package/src/config/extension-defaults.yaml +34 -1
- package/src/github/comment-id-guard.mjs +97 -9
- package/src/github/copilot-helpers.mjs +114 -5
- package/src/github/gh.mjs +94 -0
- package/src/github/issue-ops.mjs +7 -0
- package/src/loop/agent-stall.mjs +4 -2
- package/src/loop/copilot-loop-iterations.mjs +2 -1
- package/src/loop/default-branch-guard.mjs +34 -1
- package/src/loop/gate-carry-forward.mjs +19 -6
- package/src/loop/gate-fanin.mjs +190 -29
- package/src/loop/handoff-envelope.mjs +12 -19
- package/src/loop/lifecycle-state.mjs +21 -2
- package/src/loop/main-checkout-ff.mjs +34 -0
- package/src/loop/markdown-sections.mjs +40 -0
- package/src/loop/normalize.mjs +7 -0
- package/src/loop/plan-file-promote-contract.mjs +14 -1
- package/src/loop/plan-file-refine-contract.mjs +92 -8
- package/src/loop/policy-constants.mjs +9 -0
- package/src/loop/pr-gate-coordination.mjs +65 -12
- package/src/loop/public-dev-loop-routing.mjs +7 -15
- package/src/loop/queue-board-sync.mjs +1 -26
- package/src/loop/queue-driver.mjs +14 -1
- package/src/loop/refinement-grill-state.mjs +3 -5
- package/src/loop/review-dispatch-plan.mjs +448 -9
- package/src/loop/reviewer-loop-state.mjs +8 -13
- package/src/loop/run-post-merge-actions.mjs +148 -0
- package/src/loop/size-budget-merge-gate.mjs +121 -0
- package/src/loop/tracker-pr-state.mjs +5 -15
- package/src/loop/ui-designer-review-scoping.mjs +171 -0
- package/src/loop/ui-review-drive.mjs +3 -1
- package/src/loop/ui-review-report.mjs +2 -5
- package/src/loop/ui-review-teardown.mjs +3 -1
- package/src/projects/list-queue-items.mjs +1 -27
- package/src/projects/move-queue-item.mjs +1 -27
- 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
|
-
|
|
191
|
-
|
|
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
|
-
/**
|
|
194
|
-
|
|
195
|
-
|
|
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 (
|
|
202
|
-
|
|
203
|
-
|
|
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 ?
|
|
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:
|
|
184
|
-
|
|
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:
|
|
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 =
|
|
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 =
|
|
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:
|
|
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",
|