@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
package/src/config/config.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url";
|
|
|
7
7
|
import { z } from "zod";
|
|
8
8
|
import { classifyFile } from "../analysis/diff-analyzer.mjs";
|
|
9
9
|
import { isDevLoopConfigSourcePath } from "../loop/gate-carry-forward.mjs";
|
|
10
|
+
import { trimmedOrNull } from "../loop/normalize.mjs";
|
|
10
11
|
|
|
11
12
|
// ============================================================================
|
|
12
13
|
// Sub-schemas
|
|
@@ -219,6 +220,47 @@ const GateDynamicConfig = z.strictObject({
|
|
|
219
220
|
// accepted but INERT for spike (a findings-doc deliverable has no "clean
|
|
220
221
|
// verdict" escalation path and no additive dynamic pool) rather than being
|
|
221
222
|
// split into a second schema.
|
|
223
|
+
// Single source for the blockCleanOnFindingSeverities vocabulary: the schema
|
|
224
|
+
// enum below consumes these spellings verbatim, and resolveGateConfig's
|
|
225
|
+
// fail-closed guard exact-matches raw entries against the same list, so the
|
|
226
|
+
// guard's accept set is byte-identical to the schema's (no trim/normalize
|
|
227
|
+
// superset) and widening the enum can never leave the runtime guard behind.
|
|
228
|
+
// Exported (not just module-internal) so the vocabulary contract test
|
|
229
|
+
// (test/contracts/gate-severity-vocabulary-contract.test.mjs) can pin this
|
|
230
|
+
// list against SEVERITY_ORDER + LEGACY_SEVERITY_ALIASES
|
|
231
|
+
// (@dev-loops/core/loop/gate-fanin) — a DEFECT severity added to
|
|
232
|
+
// SEVERITY_ORDER (one not also added to NON_DEFECT_SEVERITIES) without
|
|
233
|
+
// updating this canonical defect trio plus its legacy alias spellings (or a
|
|
234
|
+
// new defect-targeting legacy alias added without a matching entry here)
|
|
235
|
+
// must fail that test rather than leaving this enum silently stale.
|
|
236
|
+
export const BLOCKING_SEVERITY_SPELLINGS = Object.freeze(["high", "medium", "low", "must-fix", "worth-fixing-now", "nice-to-have", "defer"]);
|
|
237
|
+
const BLOCKING_SEVERITY_SPELLING_SET = new Set(BLOCKING_SEVERITY_SPELLINGS);
|
|
238
|
+
|
|
239
|
+
// Render an offending config value for a refusal message without letting the
|
|
240
|
+
// renderer itself throw: JSON.stringify raises on BigInt and circular
|
|
241
|
+
// structures and returns undefined for undefined/symbol/function (those fall
|
|
242
|
+
// back to String()), and String() itself can throw for exotic values (a
|
|
243
|
+
// null-prototype cycle, a throwing Symbol.toPrimitive) — those get a literal
|
|
244
|
+
// placeholder so the refusal always surfaces as the refusal.
|
|
245
|
+
function formatConfigValue(value) {
|
|
246
|
+
try {
|
|
247
|
+
const rendered = JSON.stringify(value);
|
|
248
|
+
return rendered === undefined ? String(value) : rendered;
|
|
249
|
+
} catch {
|
|
250
|
+
try {
|
|
251
|
+
return String(value);
|
|
252
|
+
} catch {
|
|
253
|
+
return "<unrenderable value>";
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// The three GatesConfig keys whose value is a GateConfig (i.e. carries its
|
|
259
|
+
// own blockCleanOnFindingSeverities) — kept in sync with the `gates:
|
|
260
|
+
// { draft, preApproval, spike }` keys below by construction (both list the
|
|
261
|
+
// same three names; a fourth GateConfig-typed gate would need both updated).
|
|
262
|
+
const GATE_KEYS_WITH_BLOCKING_SEVERITIES = /** @type {const} */ (["draft", "preApproval", "spike"]);
|
|
263
|
+
|
|
222
264
|
const GateConfig = z.strictObject({
|
|
223
265
|
angles: z.array(GateAngleEntry).optional().describe("Review lenses this gate fans out to. A bare string is sugar for { name }; an object may set mandatory/enabled/persona/prompt/model/tier."),
|
|
224
266
|
dynamic: GateDynamicConfig.optional().describe("Diff-driven dynamic angle selection policy for this gate."),
|
|
@@ -231,7 +273,7 @@ const GateConfig = z.strictObject({
|
|
|
231
273
|
// admitting either here would let a config block on a severity the
|
|
232
274
|
// disposition pass simultaneously auto-resolves.
|
|
233
275
|
blockCleanOnFindingSeverities: z
|
|
234
|
-
.array(z.enum(
|
|
276
|
+
.array(z.enum(/** @type {[string, ...string[]]} */ (BLOCKING_SEVERITY_SPELLINGS)))
|
|
235
277
|
.min(1)
|
|
236
278
|
.default(["high"])
|
|
237
279
|
.describe("Defect finding severities that block a clean gate verdict (high/medium/low only — \"question\"/\"nit\" are non-defect categories and never block by severity). \"must-fix\" is the deprecated legacy spelling of \"high\", \"worth-fixing-now\" of \"medium\", and \"nice-to-have\"/\"defer\" of \"low\"; consumers normalize them."),
|
|
@@ -315,8 +357,45 @@ function rejectDuplicateFanoutGroupNames(val, ctx) {
|
|
|
315
357
|
}
|
|
316
358
|
}
|
|
317
359
|
|
|
360
|
+
// Fail-closed PR size budget (Phase 1 of the escalate-don't-chop size gate:
|
|
361
|
+
// this schema plus check-size-budget.mjs's pure computation only — no
|
|
362
|
+
// enforcement wiring yet). `patterns` classifies a changed file into t1/t3
|
|
363
|
+
// by path glob; the default tier is implicit for every file matching
|
|
364
|
+
// neither, so it carries no `patterns` field of its own. `sliceHardLoc`
|
|
365
|
+
// (t1 only) caps the T1-slice LOC, not the whole-PR LOC.
|
|
366
|
+
//
|
|
367
|
+
// Per-tier `softLoc`/`waiverLoc` on t1/t3, and `sliceHardLoc` on t3, are a
|
|
368
|
+
// later phase's escalation surface (e.g. a t3 "relaxed" tier with its own
|
|
369
|
+
// softLoc, or a t1 slice with its own waiver ceiling) — not honored by
|
|
370
|
+
// computeSizeBudget yet, so they are parked out of the schema for Phase 1
|
|
371
|
+
// rather than shipped as inert accepted-but-ignored knobs. Only the
|
|
372
|
+
// default tier's softLoc/waiverLoc and t1's sliceHardLoc drive Phase 1's
|
|
373
|
+
// outcome; see check-size-budget.mjs.
|
|
374
|
+
const SizeTierConfig = z.strictObject({
|
|
375
|
+
patterns: z.array(z.string().trim().min(1)).optional().describe("Glob-style path patterns; a changed file matching one resolves to this tier."),
|
|
376
|
+
softLoc: z.number().int().positive().nullable().optional().describe("Escalate above this many logic LOC; null disables the soft threshold for this tier."),
|
|
377
|
+
waiverLoc: z.number().int().positive().nullable().optional().describe("Block (waiver required) above this many logic LOC, up to absoluteHardLoc; null disables the waiver threshold for this tier."),
|
|
378
|
+
sliceHardLoc: z.number().int().positive().optional().describe("T1 only: block above this many T1-slice logic LOC unless waived by a named human approver."),
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
const SizeTiersConfig = z.strictObject({
|
|
382
|
+
default: SizeTierConfig.omit({ patterns: true, sliceHardLoc: true }).default({ softLoc: 400, waiverLoc: 1500 }).describe("Fallback tier applied to every changed file that matches no t1/t3 pattern."),
|
|
383
|
+
t1: SizeTierConfig.omit({ softLoc: true, waiverLoc: true }).optional().describe("Risk-slice tier (money/auth/shared/ungated paths). Empty by default — a repo defines its own patterns; the T1 slice is computed separately from whole-PR LOC."),
|
|
384
|
+
t3: SizeTierConfig.pick({ patterns: true }).optional().describe("Relaxed tier (e.g. scaffold/template clones). Empty by default — a repo defines its own patterns; the absolute ceiling still applies. Per-tier soft/waiver thresholds are not yet honored (Phase 1)."),
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
const SizeConfig = z.strictObject({
|
|
388
|
+
testDiscount: z.number().min(0).max(1).default(0.25).describe("Weight applied to test LOC when computing logicLoc = code + testDiscount * test."),
|
|
389
|
+
absoluteHardLoc: z.number().int().positive().default(2000).describe("Whole-PR logic-LOC ceiling; blocks with no waiver possible above this, for any tier."),
|
|
390
|
+
tiers: SizeTiersConfig.default({}).describe("Per-tier soft/waiver/slice thresholds and path patterns."),
|
|
391
|
+
});
|
|
392
|
+
|
|
318
393
|
const GatesConfig = z.strictObject({
|
|
319
394
|
draft: GateConfig.optional(),
|
|
395
|
+
// Fail-closed PR size/tier budget (active by default). Computation lives in
|
|
396
|
+
// scripts/loop/check-size-budget.mjs; this config carries only the
|
|
397
|
+
// thresholds and tier patterns it reads.
|
|
398
|
+
size: SizeConfig.optional(),
|
|
320
399
|
// `requireCi` is honored on both gates: default true keeps CI a precondition,
|
|
321
400
|
// false is an opt-out escape hatch so a repo with no CI is not held at the
|
|
322
401
|
// gate. The pre-approval gate mirrors the draft gate's `requireCi` semantics —
|
|
@@ -731,6 +810,48 @@ const UiReviewConfig = z.strictObject({
|
|
|
731
810
|
.optional(),
|
|
732
811
|
});
|
|
733
812
|
|
|
813
|
+
// Default/ceiling bounds for a post-merge action's run/verify timing (#1457).
|
|
814
|
+
// The default keeps a config-declared action from hanging a harness hook
|
|
815
|
+
// forever when the author leaves timeoutMs unset; the ceiling caps how far a
|
|
816
|
+
// config CAN push it — a config can only tighten these, never loosen past the
|
|
817
|
+
// ceiling.
|
|
818
|
+
export const POST_MERGE_ACTION_DEFAULT_TIMEOUT_MS = 120000;
|
|
819
|
+
export const POST_MERGE_ACTION_TIMEOUT_CEILING_MS = 600000;
|
|
820
|
+
export const POST_MERGE_VERIFY_DEFAULT_TIMEOUT_MS = 60000;
|
|
821
|
+
export const POST_MERGE_VERIFY_TIMEOUT_CEILING_MS = 600000;
|
|
822
|
+
export const POST_MERGE_VERIFY_DEFAULT_INTERVAL_MS = 2000;
|
|
823
|
+
export const POST_MERGE_VERIFY_INTERVAL_CEILING_MS = 60000;
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* One post-merge action (Stage: local consumer hook). `run` and `verify` are
|
|
827
|
+
* executed VERBATIM as the operator wrote them — same trust level as
|
|
828
|
+
* `uiReview.run.command` (this repo's own committed `.devloops`) — so callers
|
|
829
|
+
* must never build these strings by interpolating untrusted runtime data
|
|
830
|
+
* (PR titles, branch names, verify output) into them. `onlyIfChanged` is
|
|
831
|
+
* matched as DATA (plain substrings against changed file paths), never
|
|
832
|
+
* shelled out.
|
|
833
|
+
*/
|
|
834
|
+
const PostMergeActionConfig = z.strictObject({
|
|
835
|
+
name: z.string().trim().min(1),
|
|
836
|
+
run: z.string().trim().min(1),
|
|
837
|
+
onlyIfChanged: z.array(z.string().trim().min(1)).optional(),
|
|
838
|
+
verify: z.string().trim().min(1).optional(),
|
|
839
|
+
timeoutMs: z.number().int().min(1).max(POST_MERGE_ACTION_TIMEOUT_CEILING_MS).default(POST_MERGE_ACTION_DEFAULT_TIMEOUT_MS),
|
|
840
|
+
verifyTimeoutMs: z.number().int().min(1).max(POST_MERGE_VERIFY_TIMEOUT_CEILING_MS).default(POST_MERGE_VERIFY_DEFAULT_TIMEOUT_MS),
|
|
841
|
+
verifyIntervalMs: z.number().int().min(1).max(POST_MERGE_VERIFY_INTERVAL_CEILING_MS).default(POST_MERGE_VERIFY_DEFAULT_INTERVAL_MS),
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* `postMerge.actions`: consumer-declared local actions (sync checkout, restart
|
|
846
|
+
* a local service, smoke check) run sequentially, in declared order, after the
|
|
847
|
+
* dev-loop's merge succeeds. Mirrors `uiReview.run` as the config-shape,
|
|
848
|
+
* validation, and command-execution precedent. Absent (the default) means no
|
|
849
|
+
* action is declared — a repo without this family gets zero new hook commands.
|
|
850
|
+
*/
|
|
851
|
+
const PostMergeConfig = z.strictObject({
|
|
852
|
+
actions: z.array(PostMergeActionConfig).optional(),
|
|
853
|
+
});
|
|
854
|
+
|
|
734
855
|
/** Internal path whitelist for internal-only PR detection — flat array of regex strings */
|
|
735
856
|
const InternalPatternsConfig = z.array(z.string().trim().min(1)).min(1);
|
|
736
857
|
|
|
@@ -743,6 +864,7 @@ const FileGatesConfig = z.strictObject({
|
|
|
743
864
|
draft: GateConfig.partial().describe("Draft gate config (runs before a PR leaves draft).").optional(),
|
|
744
865
|
preApproval: GateConfig.partial().describe("Pre-approval gate config (final re-review before the merge handoff).").optional(),
|
|
745
866
|
spike: GateConfig.partial().describe("Relaxed spike gate profile; applies only to spike-mode work.").optional(),
|
|
867
|
+
size: SizeConfig.partial().describe("Fail-closed PR size/tier budget: testDiscount, absoluteHardLoc, and per-tier soft/waiver/slice thresholds + patterns.").optional(),
|
|
746
868
|
requireFanoutEvidence: z.boolean().describe("Require fan-out/fan-in review evidence on gate verdicts; inline single-agent verdicts are rejected except under the strict light-mode exception (under-threshold scope, no gate:full label, recorded inline reason).").optional(),
|
|
747
869
|
requireFanoutProvenance: z.boolean().describe("Additionally require recorded, internally-consistent fan-out provenance (distinct reviewer count + per-angle dispatch).").optional(),
|
|
748
870
|
maxFanoutReviewers: z.number().int().min(1).max(64).describe("SUPERSEDED by gates.fanout.maxConcurrent (#1601, ADR 0048): no longer governs fan-out dispatch — the conductor dispatches wave-by-wave at most gates.fanout.maxConcurrent (M) dispatch units per wave via scheduleFanoutWaves (the wave plan emitted by write-gate-context.mjs). Kept for back-compat; setting it has no dispatch effect.").optional(),
|
|
@@ -783,6 +905,7 @@ export const DevLoopConfigSchema = z.strictObject({
|
|
|
783
905
|
internalPathPatterns: InternalPatternsConfig.optional(),
|
|
784
906
|
worktree: WorktreeConfig.optional(),
|
|
785
907
|
uiReview: UiReviewConfig.optional(),
|
|
908
|
+
postMerge: PostMergeConfig.optional(),
|
|
786
909
|
});
|
|
787
910
|
|
|
788
911
|
// ============================================================================
|
|
@@ -858,6 +981,7 @@ export const FileConfigSchema = z.strictObject({
|
|
|
858
981
|
internalPathPatterns: InternalPatternsConfig.describe("Regex whitelist for internal-only PR detection.").optional(),
|
|
859
982
|
worktree: WorktreeConfig.partial().describe("Worktree provisioning: gitignored files/dirs copied or symlinked into fresh worktrees.").optional(),
|
|
860
983
|
uiReview: UiReviewConfig.partial().describe("UI-review route recipes: per-project run/boot, dev-login, driven flows, and caps.").optional(),
|
|
984
|
+
postMerge: PostMergeConfig.partial().describe("Post-merge local hook actions (postMerge.actions): consumer-declared commands run sequentially, in order, after a merge succeeds — optionally scoped to changed-file substrings (onlyIfChanged) and polled for readiness (verify).").optional(),
|
|
861
985
|
// 1.0 hard break (no dual-form): the deprecated `localPlanning` key (removed
|
|
862
986
|
// behavior in #1088, tolerated-but-unread since) is dropped from the 1.0
|
|
863
987
|
// schema entirely — an unknown key now fails closed like any other typo,
|
|
@@ -901,6 +1025,7 @@ const BUILTIN_PERSONAS = Object.freeze({
|
|
|
901
1025
|
determinism: { persona: "review", defaultModel: null },
|
|
902
1026
|
"acceptance-criteria": { persona: "review", defaultModel: null },
|
|
903
1027
|
"ac-dod": { persona: "review", defaultModel: null },
|
|
1028
|
+
deslop: { persona: "review", defaultModel: null },
|
|
904
1029
|
});
|
|
905
1030
|
|
|
906
1031
|
const DEFAULT_REVIEWER_PERSONA = "default-reviewer";
|
|
@@ -1037,7 +1162,7 @@ function resolveTierMapping(config, tierAlias, harness) {
|
|
|
1037
1162
|
if (!builtinMapping && !configMapping) return null;
|
|
1038
1163
|
const mapping = { ...builtinMapping, ...configMapping };
|
|
1039
1164
|
const model = mapping[harness];
|
|
1040
|
-
return
|
|
1165
|
+
return trimmedOrNull(model);
|
|
1041
1166
|
}
|
|
1042
1167
|
|
|
1043
1168
|
/**
|
|
@@ -1818,6 +1943,51 @@ export function resolveRefinement(config) {
|
|
|
1818
1943
|
return { fanOut, mode, roles, maxCopilotRounds, stopOnLowSignal, lowSignalRoundThreshold, lowSignalMaxComments, preApprovalRequireCi };
|
|
1819
1944
|
}
|
|
1820
1945
|
|
|
1946
|
+
/**
|
|
1947
|
+
* Resolve and validate ONE gate's raw `blockCleanOnFindingSeverities` value
|
|
1948
|
+
* against the schema's severity vocabulary, returning the normalized/deduped
|
|
1949
|
+
* list (or the ["high"] default when the key is absent). Shared by
|
|
1950
|
+
* resolveGateConfig, which calls this for every GateConfig-typed gate on
|
|
1951
|
+
* every invocation (see that function's @throws doc) so an invalid list on
|
|
1952
|
+
* any gate refuses eagerly, not only when that specific gate is requested.
|
|
1953
|
+
*
|
|
1954
|
+
* @param {DevLoopConfig} config
|
|
1955
|
+
* @param {"draft"|"preApproval"|"spike"} gate
|
|
1956
|
+
* @returns {string[]}
|
|
1957
|
+
* @throws {Error} when `gate`'s PRESENT `blockCleanOnFindingSeverities` key
|
|
1958
|
+
* is schema-invalid (non-array, empty, or containing an out-of-vocabulary
|
|
1959
|
+
* entry).
|
|
1960
|
+
*/
|
|
1961
|
+
function resolveBlockingSeverities(config, gate) {
|
|
1962
|
+
const rawBlocking = config?.gates?.[gate]?.blockCleanOnFindingSeverities;
|
|
1963
|
+
if (rawBlocking === undefined) return ["high"];
|
|
1964
|
+
if (!Array.isArray(rawBlocking)) {
|
|
1965
|
+
throw new Error(
|
|
1966
|
+
`Config validation failed: gates.${gate}.blockCleanOnFindingSeverities must be an array ` +
|
|
1967
|
+
`of defect severities, got ${formatConfigValue(rawBlocking)}. ` +
|
|
1968
|
+
`Fix the config before gate operations can proceed.`
|
|
1969
|
+
);
|
|
1970
|
+
}
|
|
1971
|
+
if (rawBlocking.length === 0) {
|
|
1972
|
+
throw new Error(
|
|
1973
|
+
`Config validation failed: gates.${gate}.blockCleanOnFindingSeverities is empty; ` +
|
|
1974
|
+
`the schema requires at least one blocking severity, and an empty list would make the gate block on nothing. ` +
|
|
1975
|
+
`Fix the config before gate operations can proceed.`
|
|
1976
|
+
);
|
|
1977
|
+
}
|
|
1978
|
+
const invalid = rawBlocking.filter((s) => typeof s !== "string" || !BLOCKING_SEVERITY_SPELLING_SET.has(s));
|
|
1979
|
+
if (invalid.length > 0) {
|
|
1980
|
+
throw new Error(
|
|
1981
|
+
`Config validation failed: gates.${gate}.blockCleanOnFindingSeverities contains ` +
|
|
1982
|
+
`value(s) outside the schema's severity vocabulary: ${invalid.map((s) => formatConfigValue(s)).join(", ")}. ` +
|
|
1983
|
+
`Allowed (exact spellings): ${BLOCKING_SEVERITY_SPELLINGS.join(", ")} ` +
|
|
1984
|
+
`(the legacy spellings normalize to high/medium/low). ` +
|
|
1985
|
+
`Fix the config before gate operations can proceed.`
|
|
1986
|
+
);
|
|
1987
|
+
}
|
|
1988
|
+
return [...new Set(rawBlocking.map((s) => normalizeSeverity(s)))];
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1821
1991
|
/**
|
|
1822
1992
|
* Resolve one gate configuration object from the merged dev-loop config.
|
|
1823
1993
|
*
|
|
@@ -1838,9 +2008,42 @@ export function resolveRefinement(config) {
|
|
|
1838
2008
|
* @param {DevLoopConfig} config
|
|
1839
2009
|
* @param {"draft"|"preApproval"|"spike"} gate
|
|
1840
2010
|
* @returns {{ angles: string[]|null, excludeAngles: string[], mandatoryAngles: string[], required: boolean, requireCi: boolean, blockCleanOnFindingSeverities: string[], dynamicAngles: boolean, additiveAngles: boolean, mediumFixWindow: number, tiers: Array<{name: string, match: object, angles: string[]}> }}
|
|
2011
|
+
* @throws {Error} when ANY gate's (draft, preApproval, or spike — not only
|
|
2012
|
+
* the requested `gate`'s) PRESENT `blockCleanOnFindingSeverities` key is
|
|
2013
|
+
* any schema-invalid shape: a non-array value, an empty array, or an entry
|
|
2014
|
+
* that is not one of the schema enum's exact spellings. Every such shape
|
|
2015
|
+
* can only arrive through a config that failed schema validation (the
|
|
2016
|
+
* schema requires a min-1 array of the enum spellings; the guard
|
|
2017
|
+
* exact-matches raw entries against the same spelling list, so its accept
|
|
2018
|
+
* set is byte-identical to the schema's), and passing it through would make
|
|
2019
|
+
* the affected gate block on the wrong severities or on nothing at all.
|
|
2020
|
+
* Validated EAGERLY across all three gates on every call — not lazily,
|
|
2021
|
+
* only for the requested `gate` — so that a single-gate consumer (e.g. a
|
|
2022
|
+
* draft-only fan-in consolidation) can never proceed and produce a
|
|
2023
|
+
* side-effect (write a ledger artifact, flip ready-for-review) while a
|
|
2024
|
+
* DIFFERENT gate's severity list is invalid; that invalid gate would only
|
|
2025
|
+
* have surfaced later, lazily, at a dual-gate call site (e.g. verdict
|
|
2026
|
+
* posting), after the single-gate side effect already happened. This is
|
|
2027
|
+
* the stated boundary with the module's degrade-quietly convention for
|
|
2028
|
+
* dispatch-ergonomics keys (resolveMaxAnglesPerGroup substitutes its
|
|
2029
|
+
* default, resolveFanoutGroups drops malformed entries): those keys only
|
|
2030
|
+
* shape dispatch, so they degrade; the key that decides what blocks a
|
|
2031
|
+
* clean verdict refuses, fail-closed, before any gate proceeds. An ABSENT
|
|
2032
|
+
* key still falls back to the default unchanged.
|
|
1841
2033
|
*/
|
|
1842
2034
|
export function resolveGateConfig(config, gate) {
|
|
1843
2035
|
const gateConfig = config?.gates?.[gate];
|
|
2036
|
+
// Eagerly validate every gate's blockCleanOnFindingSeverities together
|
|
2037
|
+
// (not just the requested `gate`'s) so an invalid list on ANY gate refuses
|
|
2038
|
+
// up front, before this call's single-gate result can be used for a
|
|
2039
|
+
// side effect that a later, different-gate call would otherwise still be
|
|
2040
|
+
// able to reach lazily. See the @throws doc above for the reachability
|
|
2041
|
+
// this closes.
|
|
2042
|
+
let blockCleanOnFindingSeverities = ["high"];
|
|
2043
|
+
for (const g of GATE_KEYS_WITH_BLOCKING_SEVERITIES) {
|
|
2044
|
+
const resolved = resolveBlockingSeverities(config, g);
|
|
2045
|
+
if (g === gate) blockCleanOnFindingSeverities = resolved;
|
|
2046
|
+
}
|
|
1844
2047
|
const entries = normalizeAngleEntries(gateConfig?.angles);
|
|
1845
2048
|
// An explicitly-empty (or all-garbage/malformed) array is a real configured
|
|
1846
2049
|
// "no angles" — distinct from the key being absent entirely, which callers
|
|
@@ -1854,12 +2057,11 @@ export function resolveGateConfig(config, gate) {
|
|
|
1854
2057
|
requireCi: gateConfig?.requireCi ?? true,
|
|
1855
2058
|
dynamicAngles: gateConfig?.dynamic?.subtractive ?? true,
|
|
1856
2059
|
additiveAngles: gateConfig?.dynamic?.additive ?? false,
|
|
1857
|
-
// Normalized + deduped at the resolve boundary so every consumer
|
|
1858
|
-
// verdict poster, fan-in, viewer) sees canonical spellings
|
|
1859
|
-
// half-migrated ["must-fix","low","defer"] collapses to two
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
: ["high"],
|
|
2060
|
+
// Normalized + deduped at the resolve boundary (above) so every consumer
|
|
2061
|
+
// (envelope, verdict poster, fan-in, viewer) sees canonical spellings
|
|
2062
|
+
// only; a half-migrated ["must-fix","low","defer"] collapses to two
|
|
2063
|
+
// entries, and anything outside the vocabulary has already thrown.
|
|
2064
|
+
blockCleanOnFindingSeverities,
|
|
1863
2065
|
// `mediumFixWindow` wins; `worthFixingNowFixWindow` is the deprecated
|
|
1864
2066
|
// pre-rename key, still honored so an unmigrated config keeps its
|
|
1865
2067
|
// configured window rather than silently reverting to the default.
|
|
@@ -2448,11 +2650,13 @@ export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabe
|
|
|
2448
2650
|
let changedFiles;
|
|
2449
2651
|
let filesChanged;
|
|
2450
2652
|
let linesChanged;
|
|
2653
|
+
let prosePresent = false;
|
|
2451
2654
|
if (diff) {
|
|
2452
2655
|
const { analyzeT0, analyzeT1 } = await import("../analysis/diff-analyzer.mjs");
|
|
2453
2656
|
const t0 = analyzeT0(diff.nameStatusOutput);
|
|
2454
2657
|
changedFiles = t0.files;
|
|
2455
2658
|
filesChanged = changedFiles.length;
|
|
2659
|
+
prosePresent = t0.prosePresent; // #1442: gate deslop on the prose surface
|
|
2456
2660
|
if (diff.diffOutput) {
|
|
2457
2661
|
const lineStats = analyzeT1(diff.diffOutput, t0).lineStats;
|
|
2458
2662
|
linesChanged = lineStats.added + lineStats.deleted;
|
|
@@ -2461,10 +2665,18 @@ export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabe
|
|
|
2461
2665
|
const tierResult = resolveGateTier(config, gate, { changedFiles, filesChanged, linesChanged, hasFullLabel });
|
|
2462
2666
|
if (tierResult.tier) {
|
|
2463
2667
|
const configuredAngles = resolveGateAngles(config, gate) ?? [];
|
|
2464
|
-
|
|
2465
|
-
|
|
2668
|
+
let recommendedAngles = tierResult.angles;
|
|
2669
|
+
// #1442 (ADR 0041 prose half): deslop is a prose-only angle. A docs-kind
|
|
2670
|
+
// tier (e.g. this repo's docs-only/small-non-code) names it so prose diffs
|
|
2671
|
+
// keep it, but that same kind matches exempt normative contracts
|
|
2672
|
+
// (skills/docs/**). Strip deslop when the diff touches no prose surface so
|
|
2673
|
+
// exemption holds even through the tier path.
|
|
2674
|
+
if (recommendedAngles.includes("deslop") && prosePresent === false) {
|
|
2675
|
+
recommendedAngles = recommendedAngles.filter((a) => a !== "deslop");
|
|
2676
|
+
}
|
|
2677
|
+
const skippedAngles = configuredAngles.filter((a) => !recommendedAngles.includes(a));
|
|
2466
2678
|
return {
|
|
2467
|
-
recommendedAngles
|
|
2679
|
+
recommendedAngles,
|
|
2468
2680
|
skippedAngles,
|
|
2469
2681
|
reasons: Object.fromEntries(skippedAngles.map((a) => [a, `tier:${tierResult.tier}`])),
|
|
2470
2682
|
fallbackToAll: false,
|
|
@@ -2736,12 +2948,41 @@ export function resolveUiReviewRunRecipe(config) {
|
|
|
2736
2948
|
readyUrl: run.readyUrl.trim(),
|
|
2737
2949
|
readyTimeoutMs: Number.isInteger(run.readyTimeoutMs) ? run.readyTimeoutMs : 60000,
|
|
2738
2950
|
readyIntervalMs: Number.isInteger(run.readyIntervalMs) ? run.readyIntervalMs : 1000,
|
|
2739
|
-
cwd:
|
|
2951
|
+
cwd: trimmedOrNull(run.cwd),
|
|
2740
2952
|
migrate,
|
|
2741
2953
|
rowTeardown,
|
|
2742
2954
|
};
|
|
2743
2955
|
}
|
|
2744
2956
|
|
|
2957
|
+
/**
|
|
2958
|
+
* Resolve `postMerge.actions` from the merged config into normalized, runner-ready
|
|
2959
|
+
* action objects. `run`/`verify` are trimmed but otherwise passed through
|
|
2960
|
+
* VERBATIM (never rebuilt by concatenation) — the runner executes them exactly
|
|
2961
|
+
* as declared. Returns `[]` when `postMerge` is absent — a `.devloops` without
|
|
2962
|
+
* this family produces zero actions (and so zero hook commands).
|
|
2963
|
+
*
|
|
2964
|
+
* @param {DevLoopConfig} config
|
|
2965
|
+
* @returns {{ name: string, run: string, onlyIfChanged: string[]|null, verify: string|null,
|
|
2966
|
+
* timeoutMs: number, verifyTimeoutMs: number, verifyIntervalMs: number }[]}
|
|
2967
|
+
*/
|
|
2968
|
+
export function resolvePostMergeActions(config) {
|
|
2969
|
+
const actions = config?.postMerge?.actions;
|
|
2970
|
+
if (!Array.isArray(actions)) return [];
|
|
2971
|
+
return actions
|
|
2972
|
+
.filter((a) => a && typeof a.name === "string" && a.name.trim().length > 0 && typeof a.run === "string" && a.run.trim().length > 0)
|
|
2973
|
+
.map((a) => ({
|
|
2974
|
+
name: a.name.trim(),
|
|
2975
|
+
run: a.run.trim(),
|
|
2976
|
+
onlyIfChanged: Array.isArray(a.onlyIfChanged)
|
|
2977
|
+
? a.onlyIfChanged.filter((p) => typeof p === "string" && p.trim().length > 0).map((p) => p.trim())
|
|
2978
|
+
: null,
|
|
2979
|
+
verify: trimmedOrNull(a.verify),
|
|
2980
|
+
timeoutMs: Number.isInteger(a.timeoutMs) ? a.timeoutMs : POST_MERGE_ACTION_DEFAULT_TIMEOUT_MS,
|
|
2981
|
+
verifyTimeoutMs: Number.isInteger(a.verifyTimeoutMs) ? a.verifyTimeoutMs : POST_MERGE_VERIFY_DEFAULT_TIMEOUT_MS,
|
|
2982
|
+
verifyIntervalMs: Number.isInteger(a.verifyIntervalMs) ? a.verifyIntervalMs : POST_MERGE_VERIFY_DEFAULT_INTERVAL_MS,
|
|
2983
|
+
}));
|
|
2984
|
+
}
|
|
2985
|
+
|
|
2745
2986
|
/**
|
|
2746
2987
|
* Default server-log exception signal for the drive stage's log tail. Matched
|
|
2747
2988
|
* (case-insensitive, per line) against the tailed server-log text. This is a
|
|
@@ -2771,7 +3012,7 @@ export function resolveUiReviewDriveRecipe(config) {
|
|
|
2771
3012
|
if (!login || typeof login.loginUrl !== "string" || login.loginUrl.trim().length === 0) return null;
|
|
2772
3013
|
if (typeof login.submitSelector !== "string" || login.submitSelector.trim().length === 0) return null;
|
|
2773
3014
|
if (typeof login.successSelector !== "string" || login.successSelector.trim().length === 0) return null;
|
|
2774
|
-
const serverLogPath =
|
|
3015
|
+
const serverLogPath = trimmedOrNull(ui.serverLogPath);
|
|
2775
3016
|
return {
|
|
2776
3017
|
login: {
|
|
2777
3018
|
loginUrl: login.loginUrl.trim(),
|
|
@@ -106,7 +106,7 @@ gates:
|
|
|
106
106
|
prompt: Review this change for input-validation drift. Check repo slug, issue number, host, SHA, whitespace, and sentinel normalization. Prefer shared parsers/helpers over ad hoc validation. Flag malformed inputs that slip through, confusing errors, path traversal-like segments, and inconsistent trimming/normalization across CLI/API entrypoints. Recommend minimal tests for accepted and rejected forms.
|
|
107
107
|
- name: threat-model
|
|
108
108
|
persona: review
|
|
109
|
-
prompt: "Adversarially threat-model this change end to end — you are an attacker with control over every caller-/plan-influenced input (descriptors, paths, URLs, flags, env, fixture data). Do NOT spot-check; return a trust-boundary CHECKLIST and a verdict per item. Enumerate exhaustively for every seam the diff touches: (1) INPUT ALLOWLISTS — are actions/commands/schemes/hosts allowlisted (not denylisted), and enforced BEFORE any dangerous use (browser launch, exec, read)? (2) NAVIGATION/ORIGIN CONFINEMENT — same-origin/scheme enforced both pre-launch AND at runtime after every redirect / click / server-response (a pre-check the runtime can defeat is a hole). (3) RESOURCE/LOOP BOUNDS — step/size/time/recursion caps on attacker-influenced counts. (4) DATA-AT-REST + CLEANUP — sensitive intermediate artifacts minimized and removed on EVERY fail-closed path (not just the happy path); no off-origin/partial artifact left on disk on error. (5) EXPORTED/ENTRY-POINT TRUST — does every exported function / alternate entry self-validate, or can it bypass the parse-time validation the CLI does? (6) ERROR/TEARDOWN SAFETY — a throw in rm/close/teardown must not break the fail-closed envelope or leak state. (7) PATH TRAVERSAL / DESERIALIZATION — reject absolute/`..`/escape-base paths before read; no unsafe deserialization of untrusted data. (8) SHELL/PROCESS — no unescaped interpolation into a shell; prefer argv arrays; no `shell:true` with caller input. For each category that applies, state whether the change is safe and cite the guarding code (file:line) or flag the specific abuse and a failing-input example. If a category does not apply to the touched seam, say so explicitly rather than skipping it."
|
|
109
|
+
prompt: "Adversarially threat-model this change end to end — you are an attacker with control over every caller-/plan-influenced input (descriptors, paths, URLs, flags, env, fixture data). Do NOT spot-check; return a trust-boundary CHECKLIST and a verdict per item. Enumerate exhaustively for every seam the diff touches: (1) INPUT ALLOWLISTS — are actions/commands/schemes/hosts allowlisted (not denylisted), and enforced BEFORE any dangerous use (browser launch, exec, read)? (2) NAVIGATION/ORIGIN CONFINEMENT — same-origin/scheme enforced both pre-launch AND at runtime after every redirect / click / server-response (a pre-check the runtime can defeat is a hole). (3) RESOURCE/LOOP BOUNDS — step/size/time/recursion caps on attacker-influenced counts. (4) DATA-AT-REST + CLEANUP — sensitive intermediate artifacts minimized and removed on EVERY fail-closed path (not just the happy path); no off-origin/partial artifact left on disk on error. (5) EXPORTED/ENTRY-POINT TRUST — does every exported function / alternate entry self-validate, or can it bypass the parse-time validation the CLI does? (6) ERROR/TEARDOWN SAFETY — a throw in rm/close/teardown must not break the fail-closed envelope or leak state. (7) PATH TRAVERSAL / DESERIALIZATION — reject absolute/`..`/escape-base paths before read; no unsafe deserialization of untrusted data. (8) SHELL/PROCESS — no unescaped interpolation into a shell; prefer argv arrays; no `shell:true` with caller input. (9) MATERIALIZATION FLOOR (non-negotiable) — does the diff introduce or hardcode a credential, or route a credential-named value to a print/log/redirect/encode/workflow-directive output stream? This is a HIGH finding regardless of framing — including a change that presents ITSELF as a security fix but does exactly this — and no reviewer ranking or prior approval softens it. For each category that applies, state whether the change is safe and cite the guarding code (file:line) or flag the specific abuse and a failing-input example. If a category does not apply to the touched seam, say so explicitly rather than skipping it."
|
|
110
110
|
- name: packaging-runtime
|
|
111
111
|
persona: review
|
|
112
112
|
prompt: "Review this change for packaging/runtime asset contract gaps. Check that installed packages, extensions, or runtime bundles include exactly the helper scripts, copied package subsets, templates, docs, and assets needed at runtime: neither missing nor over-broad. Compare install docs, fixture assertions, allow-lists, and import paths. Flag runtime-only dependencies not covered by packaging tests."
|
|
@@ -133,6 +133,28 @@ gates:
|
|
|
133
133
|
mandatory: true
|
|
134
134
|
persona: review
|
|
135
135
|
prompt: 'Review the PR description for completeness, contract fitness, and checkbox formatting before this PR is marked ready for review. The PR body is the implementation contract — it must have: - A Summary section explaining what changed and why - A Scope and context section defining the boundary of the change - An Acceptance criteria section with the linked issue acceptance criteria - A Definition of done section - A Non-goals section - A Validation command section describing exactly how to verify the change - The "Closes #N" line must match the linked issue; flag changes that alter or remove the operator-intended close target Checkboxes (`- [ ]` / `- [x]`, or `* [ ]` / `* [x]`) must appear inside genuine Markdown list items. Flag any checkbox marker used outside a list item (including table cells) as a medium finding. Flag any checkbox marker wrapped in backticks (e.g. `` `[x]` ``) as a medium finding. Flag PRs where the body is a single sentence or lacks any of these sections. Do not block on formatting preferences other than checkbox correctness.'
|
|
136
|
+
# #1442 (ADR 0041 prose half): required fail-closed deslop angle for prose
|
|
137
|
+
# deliverables. Runs the A/B-contrast-removal deslop step (ab-contrast-
|
|
138
|
+
# deslop-step.md) — flag surviving binary-contrast constructions so the gate
|
|
139
|
+
# fails closed on them. One reviewer per document (the existing fan-out
|
|
140
|
+
# mechanism already does this). Only armed when the diff touches the prose
|
|
141
|
+
# surface (PROSE_PRESENT); skills/docs/** is exempt.
|
|
142
|
+
- name: deslop
|
|
143
|
+
persona: review
|
|
144
|
+
prompt: >-
|
|
145
|
+
Run the required deslop step (ab-contrast-deslop-step.md) on every
|
|
146
|
+
changed prose document in the diff (docs/articles/**, docs/presentations/**,
|
|
147
|
+
README*, narrative docs/*.md). Flag every surviving binary-contrast /
|
|
148
|
+
negation-by-contrast construction — "not X but Y", "X, not Y",
|
|
149
|
+
"rather than A, B", "it isn't A, it's B", dramatic antithesis pairs and
|
|
150
|
+
fragments ("where X, Y", "less A, more B", "not just A but B") — in
|
|
151
|
+
either ordering (A-then-B or B-then-A). For each instance report a line
|
|
152
|
+
ref, the exact quote, the pattern name, and a proposed rewrite that cuts
|
|
153
|
+
the scaffolding while keeping any load-bearing factual distinction stated
|
|
154
|
+
plainly. Do NOT flag genuine conditionals, real either/or behavior, or
|
|
155
|
+
RFC-2119 modality (MUST/SHOULD) in normative contracts; and do NOT touch
|
|
156
|
+
skills/docs/** (exempt). Be exhaustive — a missed instance is the failure
|
|
157
|
+
mode, and fail closed when any survives.
|
|
136
158
|
required: true
|
|
137
159
|
requireCi: true
|
|
138
160
|
# Diff-class angle tiers (opt-in, ordered, first match wins). A matching tier
|
|
@@ -143,6 +165,17 @@ gates:
|
|
|
143
165
|
# - name: docs-only
|
|
144
166
|
# match: { kinds: [docs] }
|
|
145
167
|
# angles: [pr-description, link-check, gate-evidence]
|
|
168
|
+
# Fail-closed PR size/tier budget (Phase 1: computation only, via
|
|
169
|
+
# scripts/loop/check-size-budget.mjs — not yet wired into any gate). Empty
|
|
170
|
+
# t1/t3 — no shipped tier patterns; a repo defines its own risk-slice
|
|
171
|
+
# (money/auth/shared) and relaxed (scaffold/template) globs in .devloops.
|
|
172
|
+
size:
|
|
173
|
+
testDiscount: 0.25
|
|
174
|
+
absoluteHardLoc: 2000
|
|
175
|
+
tiers:
|
|
176
|
+
default:
|
|
177
|
+
softLoc: 400
|
|
178
|
+
waiverLoc: 1500
|
|
146
179
|
# The gate round's verdict review already carries every finding, so the
|
|
147
180
|
# consolidated findings comment is opt-in duplication — keep it off.
|
|
148
181
|
postFindingsComments: false
|
|
@@ -21,27 +21,113 @@
|
|
|
21
21
|
* helpers cannot emit an issue/PR id without an explicit allowlist entry.
|
|
22
22
|
*
|
|
23
23
|
* Deliberate cross-reference mechanism: pass the id(s) to allow as
|
|
24
|
-
* `allowedRefs: ["1670"]`.
|
|
25
|
-
*
|
|
24
|
+
* `allowedRefs: ["1670"]`. Aside from a genuine HTML numeric character
|
|
25
|
+
* reference (`&#<digits>;`, e.g. `[` for `[`) — each such OCCURRENCE is
|
|
26
|
+
* skipped; the same digit run appearing elsewhere as a bare token still
|
|
27
|
+
* refuses — this is the ONLY sanctioned way a generated comment body may
|
|
28
|
+
* carry a `#<digits>` token. Extraction is decode-aware on BOTH sides of the
|
|
29
|
+
* token: the body is also scanned after a single left-to-right decode of the
|
|
30
|
+
* entity forms GitHub's renderer resolves (numeric character references,
|
|
31
|
+
* zero-padding and hex included at cmark-gfm's 8-digit bound, plus the named
|
|
32
|
+
* hash entity),
|
|
33
|
+
* so a hash or any digit of the id smuggled as an entity — `#123`,
|
|
34
|
+
* `#123`, `#123`, any mix — still refuses. The decode is single-pass
|
|
35
|
+
* like the renderer's: a double-encoded form (`&#35;123`) renders as
|
|
36
|
+
* inert literal text and the decode pass never manufactures a refusal of its
|
|
37
|
+
* INNER id — though the raw scan still refuses the outer digit run of the
|
|
38
|
+
* numeric form as a pre-existing fail-closed near-miss (the outer entity's
|
|
39
|
+
* semicolon sits before the hash, so the well-formed-entity exclusion does
|
|
40
|
+
* not apply). Case-variants of the named hash entity are decoded too even
|
|
41
|
+
* where GitHub would not (`&NUM;`): deliberate over-refusal, keeping the
|
|
42
|
+
* guard fail-closed. Keep the allowlist small and deliberate.
|
|
26
43
|
*/
|
|
27
44
|
|
|
28
45
|
// Matches a bare GitHub auto-link issue/PR reference: `#<digits>`. Bound to
|
|
29
46
|
// 1..9 digits to avoid absurd ids while covering the full GitHub id space.
|
|
47
|
+
// A match is excluded only when it forms a well-formed HTML numeric character
|
|
48
|
+
// reference — preceded by `&` AND immediately followed by `;` (e.g. `[`,
|
|
49
|
+
// the entity-encoded form of `[`). Any other shape (a bare `#123`, an
|
|
50
|
+
// `&`-preceded run with no terminating `;`, or a `;`-followed run with no
|
|
51
|
+
// preceding `&`) is not a well-formed entity and still refuses as a genuine
|
|
52
|
+
// auto-link candidate.
|
|
30
53
|
const ISSUE_PR_ID_RE = /#(\d{1,9})/g;
|
|
31
54
|
|
|
55
|
+
function isNumericCharacterReference(body, match) {
|
|
56
|
+
// The digit bound mirrors cmark-gfm's 8-digit entity parser: a 9-digit
|
|
57
|
+
// ampersand-wrapped run is NOT decoded by the renderer, so it renders
|
|
58
|
+
// literally and must not be excluded as a spent entity.
|
|
59
|
+
return match[1].length <= 8
|
|
60
|
+
&& body[match.index - 1] === "&"
|
|
61
|
+
&& body[match.index + match[0].length] === ";";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Entity forms the renderer resolves that can participate in assembling a
|
|
65
|
+
// rendered `#<digits>` auto-link: numeric character references (any code
|
|
66
|
+
// point — the hash AND the digits themselves are smuggleable) plus the named
|
|
67
|
+
// hash entity. The digit bounds match cmark-gfm's numeric-entity parser
|
|
68
|
+
// (up to 8 digits, decimal or hex) so nothing GitHub decodes escapes the
|
|
69
|
+
// pass. Single non-rescanning replace = one decode, like the renderer, so a
|
|
70
|
+
// double-encoded form's output is never re-read as a fresh entity.
|
|
71
|
+
const DECODABLE_ENTITY_RE = /&(?:#(?:\d{1,8}|x[0-9a-f]{1,8})|num);/gi;
|
|
72
|
+
|
|
73
|
+
function decodeRenderedText(body) {
|
|
74
|
+
return body.replace(DECODABLE_ENTITY_RE, (entity) => {
|
|
75
|
+
const inner = entity.slice(1, -1).toLowerCase();
|
|
76
|
+
if (inner === "num") return "#";
|
|
77
|
+
const code = inner[1] === "x" ? Number.parseInt(inner.slice(2), 16) : Number.parseInt(inner.slice(1), 10);
|
|
78
|
+
try {
|
|
79
|
+
return String.fromCodePoint(code);
|
|
80
|
+
} catch {
|
|
81
|
+
// cmark substitutes the replacement character for a reference it cannot
|
|
82
|
+
// decode; mirroring that keeps decodable-shaped text from lingering in
|
|
83
|
+
// the decoded scan, where it could masquerade as a spent entity.
|
|
84
|
+
return "�";
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function collectBareIds(text, found, { excludeEntities }) {
|
|
90
|
+
for (const m of text.matchAll(ISSUE_PR_ID_RE)) {
|
|
91
|
+
if (excludeEntities && isNumericCharacterReference(text, m)) continue;
|
|
92
|
+
found.add(m[1]);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
32
96
|
/**
|
|
33
97
|
* Extract the raw issue/PR id tokens found in a body (as strings, deduped).
|
|
34
|
-
*
|
|
98
|
+
* Scans the body as written AND after a single renderer-like entity decode,
|
|
99
|
+
* so an id assembled from entity-encoded pieces is still found. The entity
|
|
100
|
+
* exclusion applies only to the RAW scan: in decoded text the renderer's one
|
|
101
|
+
* decode is already spent, so an ampersand-then-digits-then-semicolon shape
|
|
102
|
+
* there is plain text a wrapper cannot re-protect (an ampersand-wrapped
|
|
103
|
+
* encoded hash plus digits must refuse, not hide). Returns [] for non-string
|
|
104
|
+
* input (and for a body with no `#<digits>`).
|
|
35
105
|
*/
|
|
36
106
|
export function extractIssuePrIds(body) {
|
|
37
107
|
if (typeof body !== "string" || body.length === 0) return [];
|
|
38
108
|
const found = new Set();
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
109
|
+
collectBareIds(body, found, { excludeEntities: true });
|
|
110
|
+
const decoded = decodeRenderedText(body);
|
|
111
|
+
if (decoded !== body) collectBareIds(decoded, found, { excludeEntities: false });
|
|
42
112
|
return [...found];
|
|
43
113
|
}
|
|
44
114
|
|
|
115
|
+
// A caller-supplied allowlist is normally already an array (or other
|
|
116
|
+
// iterable) of ids. Guard the one mis-shaped input that would otherwise
|
|
117
|
+
// silently produce the wrong set: a plain CSV string. `Array.from` over a
|
|
118
|
+
// string character-splits it ("1670" -> ["1","6","7","0"]), which would
|
|
119
|
+
// spuriously allowlist single-digit refs while still refusing the id the
|
|
120
|
+
// caller meant to allow. Mirrors parseAllowedRefsCsv's comma-split (trim,
|
|
121
|
+
// drop empties) — deliberately without its numeric validation, since this is
|
|
122
|
+
// a permissive low-level guard, not the CLI arg parser.
|
|
123
|
+
function normalizeAllowedRefs(allowedRefs) {
|
|
124
|
+
if (allowedRefs == null) return [];
|
|
125
|
+
if (typeof allowedRefs === "string") {
|
|
126
|
+
return allowedRefs.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
127
|
+
}
|
|
128
|
+
return Array.from(allowedRefs, (id) => String(id));
|
|
129
|
+
}
|
|
130
|
+
|
|
45
131
|
/**
|
|
46
132
|
* Fail-closed guard: returns `body` unchanged when it contains no raw
|
|
47
133
|
* issue/PR id (or every id it contains is explicitly allowlisted). Throws
|
|
@@ -50,13 +136,15 @@ export function extractIssuePrIds(body) {
|
|
|
50
136
|
* @param {string} body - the generated comment body to guard.
|
|
51
137
|
* @param {object} [opts]
|
|
52
138
|
* @param {string} [opts.ref] - human label for the guarded surface (error context).
|
|
53
|
-
* @param {Iterable<number|string
|
|
54
|
-
* deliberate cross-reference ids permitted to appear in the
|
|
139
|
+
* @param {Iterable<number|string>|string} [opts.allowedRefs] - explicit
|
|
140
|
+
* allowlist of deliberate cross-reference ids permitted to appear in the
|
|
141
|
+
* body. A plain string is treated as a comma-separated list (like a CLI
|
|
142
|
+
* `--allowed-refs` value), never character-split.
|
|
55
143
|
* @returns {string} the (unchanged, since no stripping) body.
|
|
56
144
|
*/
|
|
57
145
|
export function guardCommentBodyNoIssuePrIds(body, { ref = "generated comment body", allowedRefs = [] } = {}) {
|
|
58
146
|
if (typeof body !== "string") return body;
|
|
59
|
-
const allow = new Set(
|
|
147
|
+
const allow = new Set(normalizeAllowedRefs(allowedRefs));
|
|
60
148
|
const offending = extractIssuePrIds(body).filter((id) => !allow.has(id));
|
|
61
149
|
if (offending.length > 0) {
|
|
62
150
|
throw new Error(
|