forgedock 1.46.0 → 1.48.0
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/bin/engine/outcome-codes.mjs +2 -1
- package/bin/engine-cli.mjs +22 -8
- package/bin/lean/conformance.e2e.test.mjs +20 -0
- package/bin/lean/conformance.mjs +99 -9
- package/bin/lean/conformance.test.mjs +121 -4
- package/bin/lean/context.mjs +108 -6
- package/bin/lean/context.test.mjs +84 -2
- package/bin/lean/coupled.mjs +38 -0
- package/bin/lean/coupled.test.mjs +57 -0
- package/bin/lean/decision-engine.mjs +5 -1
- package/bin/lean/decision-engine.test.mjs +21 -0
- package/bin/lean/drift.mjs +3 -2
- package/bin/lean/drift.test.mjs +23 -1
- package/bin/lean/effects.mjs +3 -2
- package/bin/lean/execution.mjs +41 -8
- package/bin/lean/execution.test.mjs +72 -2
- package/bin/lean/integration.mjs +2 -2
- package/bin/lean/integration.test.mjs +22 -1
- package/bin/lean/issuewatch.mjs +134 -37
- package/bin/lean/issuewatch.test.mjs +123 -1
- package/bin/lean/lane.mjs +13 -0
- package/bin/lean/lane.test.mjs +28 -1
- package/bin/lean/lean.mjs +60 -12
- package/bin/lean/lean.test.mjs +80 -0
- package/bin/lean/limits.mjs +6 -2
- package/bin/lean/limits.test.mjs +86 -0
- package/bin/lean/milestonesync.mjs +2 -2
- package/bin/lean/milestonesync.test.mjs +22 -0
- package/bin/lean/orchestrate.mjs +79 -9
- package/bin/lean/probe.mjs +4 -2
- package/bin/lean/probe.test.mjs +4 -0
- package/bin/lean/prompts.mjs +18 -11
- package/bin/lean/rotator.mjs +96 -0
- package/bin/lean/rotator.test.mjs +143 -0
- package/bin/lean/routecheck.mjs +48 -0
- package/bin/lean/routecheck.test.mjs +69 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +9 -0
- package/bin/lean/sandbox-remote.mjs +4 -2
- package/bin/lean/verify.mjs +2 -1
- package/bin/review-staging.mjs +20 -3
- package/commands/deploy-info.md +4 -4
- package/commands/issue.md +6 -1
- package/commands/review-pr-agents/frontend.md +1 -0
- package/commands/review-pr-agents/infra.md +2 -0
- package/commands/review-pr-staging.md +2 -0
- package/commands/work-on/build/context.md +7 -1
- package/commands/work-on/investigate.md +15 -3
- package/docs/CONFIG.md +21 -2
- package/docs/ISSUE-WATCH.md +22 -1
- package/docs/OUTCOME-CODES.md +2 -1
- package/package.json +1 -1
- package/scripts/file-deferred-followups.sh +125 -28
- package/scripts/file-deferred-followups.test.sh +49 -0
- package/scripts/issue-create.sh +7 -0
- package/scripts/issue-create.test.sh +13 -0
- package/scripts/issue-dedup.sh +5 -4
- package/scripts/issue-dedup.test.sh +15 -0
- package/scripts/owner-route-parity.test.sh +32 -0
- package/scripts/plan-validate-live.mjs +18 -6
- package/scripts/prior-attempts.mjs +72 -0
- package/scripts/prior-attempts.test.mjs +52 -0
- package/scripts/review-finalize.sh +35 -4
- package/scripts/review-finalize.test.sh +10 -0
- package/scripts/review-prepare.sh +16 -4
- package/scripts/review-prepare.test.sh +8 -0
- package/scripts/review-roster.sh +3 -1
- package/scripts/review-roster.test.sh +30 -2
- package/scripts/review-staging-pre.sh +8 -6
- package/scripts/review-staging-pre.test.sh +11 -1
|
@@ -57,8 +57,9 @@ export const OUTCOME_CODES = Object.freeze({
|
|
|
57
57
|
ENV_REPRO_UNRUNNABLE: "env.repro_unrunnable", // forge#4334: the repro harness could not run the repro test on head; an environment error, not a conformance failure
|
|
58
58
|
ENV_LANE_LIMIT: "env.lane_limit", // forge#4690: the lane hit its own scope limit (TasksMax or MemoryMax) and was stopped; an environment error, never an engine-error, so the breaker ignores it
|
|
59
59
|
ENV_CAPABILITY_REMOTE: "env.capability_remote", // forge#4542: a remote sandbox lane blocked only on a host-only capability (SOPS key, ops credentials, prod monitor SSH) the dispatching host has; recovered locally
|
|
60
|
-
SANDBOX_START_FAILED: "sandbox.start_failed", // forge#4254: a lane sandbox could not start; needs-human, not relaunched
|
|
60
|
+
SANDBOX_START_FAILED: "sandbox.start_failed", // forge#4254: a lane sandbox could not start (a non-auth 4xx/config error or a non-HTTP start failure); needs-human, not relaunched
|
|
61
61
|
SANDBOX_CAPACITY: "sandbox.capacity", // forge#4254: the sandbox provider had no capacity; deferred with backoff
|
|
62
|
+
SANDBOX_TRANSIENT: "sandbox.transient", // forge#5095: the sandbox provider answered a 5xx on launch (router/agent infrastructure); deferred with backoff, capped by max_deferrals, never needs-human
|
|
62
63
|
SANDBOX_AUTH: "sandbox.auth", // forge#4685: the sandbox provider refused or lacked the credential (HTTP 401/403, no token); deferred, never needs-human
|
|
63
64
|
SANDBOX_UNREACHABLE: "sandbox.unreachable", // forge#4672: the sandbox provider stayed unreachable past execution.unreachable_window_min; deferred, the sandbox is kept for adopt
|
|
64
65
|
LEASE_HELD: "lease.held",
|
package/bin/engine-cli.mjs
CHANGED
|
@@ -27,6 +27,7 @@ import { normalizeKindModes } from "./lean/conformance.mjs";
|
|
|
27
27
|
import { viewIssue } from "./lean/gh-assoc.mjs";
|
|
28
28
|
import { otherLaneOwner } from "./lean/lanelock.mjs";
|
|
29
29
|
import { parseExecution, detectNeeds, placementText } from "./lean/execution.mjs";
|
|
30
|
+
import { openRotator } from "./lean/rotator.mjs";
|
|
30
31
|
import { relayPrefetchCommands } from "./lean/relay.mjs";
|
|
31
32
|
import { parseTurnBudgets, formatTurnSummary, TURN_BUDGET_DEFAULTS } from "./lean/budget.mjs";
|
|
32
33
|
import { writeAtomic as writeResultAtomic } from "./lean/batch.mjs";
|
|
@@ -285,6 +286,10 @@ export function readSequenceRegenerate(cwd = process.cwd()) {
|
|
|
285
286
|
export function readFollowupsDefaultMilestone(cwd = process.cwd()) {
|
|
286
287
|
try { return String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.followups?.default_milestone ?? "").trim() || undefined; } catch { return undefined; }
|
|
287
288
|
}
|
|
289
|
+
/** #5078: forge.yaml `followups.rollup_issue_label` (a repo's own open issue with this label is the post-deploy rollup), or undefined. */
|
|
290
|
+
export function readFollowupsRollupLabel(cwd = process.cwd()) {
|
|
291
|
+
try { return String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.followups?.rollup_issue_label ?? "").trim() || undefined; } catch { return undefined; }
|
|
292
|
+
}
|
|
288
293
|
/** #4389: forge.yaml `post_deploy.precondition_cmd` (exit 0 = ready, 1 = parked, other = ignored), or null. */
|
|
289
294
|
export function readPreconditionCmd(cwd = process.cwd()) {
|
|
290
295
|
try {
|
|
@@ -527,10 +532,12 @@ export function readEnvConfig(cwd = process.cwd()) {
|
|
|
527
532
|
const runners = tr && typeof tr === "object" && !Array.isArray(tr) ? Object.fromEntries(Object.entries(tr).filter(([k, v]) => k && typeof v === "string" && v.trim())) : {};
|
|
528
533
|
const rm = readYamlMap(cwd, "validate", "recipes"); // #5020: y?.validate?.recipes, a block map {match regex: replacement} applied to frozen validate and repro commands
|
|
529
534
|
const recipes = Object.entries(rm ?? {}).map(([match, replace]) => ({ match, replace }));
|
|
535
|
+
const cf = readYamlMap(cwd, "verification", "coupled_files"); // #5079: {when glob: required glob[, glob]}
|
|
530
536
|
const s = (v) => (typeof v === "string" && v.trim() ? v.trim() : undefined);
|
|
537
|
+
const rr = s(y?.topology?.route_reachability); // #5087: repo-supplied command: exits non-zero when the ingress does not deliver the added route paths (args) to their service
|
|
531
538
|
const kb = typeof e.sparse_max_kb === "string" && /^\d+$/.test(e.sparse_max_kb.trim()) ? Number(e.sparse_max_kb) : e.sparse_max_kb;
|
|
532
539
|
return { ...(s(e.toolchain) ? { toolchainOverride: s(e.toolchain) } : {}), ...(s(e.setup) ? { envSetup: s(e.setup) } : {}),
|
|
533
|
-
...(Number.isInteger(kb) && kb >= 0 ? { sparseMaxKb: kb } : {}), ...(Object.keys(runners).length ? { testRunners: runners } : {}), ...(recipes.length ? { validateRecipes: recipes } : {}) };
|
|
540
|
+
...(Number.isInteger(kb) && kb >= 0 ? { sparseMaxKb: kb } : {}), ...(Object.keys(runners).length ? { testRunners: runners } : {}), ...(recipes.length ? { validateRecipes: recipes } : {}), ...(cf && Object.keys(cf).length ? { coupledFiles: cf } : {}), ...(rr ? { routeReachability: rr } : {}) };
|
|
534
541
|
} catch { return {}; }
|
|
535
542
|
}
|
|
536
543
|
export const ENGINE_PATHS = ["core", "full", "lean", "auto"];
|
|
@@ -981,9 +988,10 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
981
988
|
let lres;
|
|
982
989
|
try {
|
|
983
990
|
lres = await runCore({ issue, repo: leanRepo, ...(lane ? { lane } : {}), laneOverride, branches: readBranches(), cwd: process.cwd(), dir, io: leanIo, path: enginePath, onProgress,
|
|
991
|
+
...(readExecution(process.cwd())?.accountRotator ? { accountRotator: readExecution(process.cwd()).accountRotator } : {}), // #5094
|
|
984
992
|
onTeardown: (fn) => { teardown = fn; }, ...(env.FORGEDOCK_BATCH_ID ? { batchId: env.FORGEDOCK_BATCH_ID } : {}), ...(placement ? { placement } : {}), ...(recover ? { recover } : {}),
|
|
985
993
|
emitTelemetry: deps.emitTelemetry ?? composeEmit(makeEmitTelemetry({ repo: leanRepo }), makeEventEmitter({ repo: leanRepo, config: readEvents() }).emit),
|
|
986
|
-
admission: readAdmission(), heavy: readHeavy(), ...readCoreLimits(), ...readEnvConfig(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(readConformanceMode() ? { conformance: readConformanceMode() } : {}), ...(readConformanceKinds() ? { conformanceKinds: readConformanceKinds() } : {}), ...(readConformanceMutation() ? { conformanceMutation: "report" } : {}), ...(readContextPack() ? { contextPack: readContextPack() } : {}), ...(readReviewIncomplete() ? { reviewIncomplete: readReviewIncomplete() } : {}), ...(readLaneInstructions() ? { laneInstructions: readLaneInstructions() } : {}), ...(readReproTest() ? { reproTest: readReproTest() } : {}), ...(readExplore() ? { explore: readExplore() } : {}), ...(readExperiment() ? { experiment: readExperiment() } : {}), light: readLight(), ...(readCloseParent() ? { closeParent: true } : {}), ...(readReviewTrigger() ? { reviewTrigger: readReviewTrigger() } : {}), ...(readVerify() ? { verify: readVerify() } : {}), ...readPrecheckOptions(), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}), ...(readFixModel() ? { fixModel: readFixModel() } : {}), ...(readBuilderRouting() ? { builderRouting: readBuilderRouting() } : {}), ...(readVerifier() ? { verifier: readVerifier() } : {}), ...(readHostCaps() ? { hostCaps: readHostCaps() } : {}), ...readRelayCaps(), ...(readSequenceRegenerate() ? { sequenceRegenerate: readSequenceRegenerate() } : {}), ...(readBaseMerge() ? { baseMerge: readBaseMerge() } : {}), ...(readFollowupsDefaultMilestone() ? { followupsDefaultMilestone: readFollowupsDefaultMilestone() } : {}) });
|
|
994
|
+
admission: readAdmission(), heavy: readHeavy(), ...readCoreLimits(), ...readEnvConfig(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(readConformanceMode() ? { conformance: readConformanceMode() } : {}), ...(readConformanceKinds() ? { conformanceKinds: readConformanceKinds() } : {}), ...(readConformanceMutation() ? { conformanceMutation: "report" } : {}), ...(readContextPack() ? { contextPack: readContextPack() } : {}), ...(readReviewIncomplete() ? { reviewIncomplete: readReviewIncomplete() } : {}), ...(readLaneInstructions() ? { laneInstructions: readLaneInstructions() } : {}), ...(readReproTest() ? { reproTest: readReproTest() } : {}), ...(readExplore() ? { explore: readExplore() } : {}), ...(readExperiment() ? { experiment: readExperiment() } : {}), light: readLight(), ...(readCloseParent() ? { closeParent: true } : {}), ...(readReviewTrigger() ? { reviewTrigger: readReviewTrigger() } : {}), ...(readVerify() ? { verify: readVerify() } : {}), ...readPrecheckOptions(), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}), ...(readFixModel() ? { fixModel: readFixModel() } : {}), ...(readBuilderRouting() ? { builderRouting: readBuilderRouting() } : {}), ...(readVerifier() ? { verifier: readVerifier() } : {}), ...(readHostCaps() ? { hostCaps: readHostCaps() } : {}), ...readRelayCaps(), ...(readSequenceRegenerate() ? { sequenceRegenerate: readSequenceRegenerate() } : {}), ...(readBaseMerge() ? { baseMerge: readBaseMerge() } : {}), ...(readFollowupsDefaultMilestone() ? { followupsDefaultMilestone: readFollowupsDefaultMilestone() } : {}), ...(readFollowupsRollupLabel() ? { followupsRollupLabel: readFollowupsRollupLabel() } : {}) });
|
|
987
995
|
} catch (e) { if (!interrupted) report({ terminalReason: "engine-error", detail: String(e?.message ?? e).slice(0, 500) }); throw e; }
|
|
988
996
|
finally { if (!interrupted) for (const [sig, f] of Object.entries(handlers)) off(sig, f); }
|
|
989
997
|
if (interrupted) return lres; // the signal handler reported and exits
|
|
@@ -1699,7 +1707,8 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1699
1707
|
const { runCore } = deps.leanModule;
|
|
1700
1708
|
const emitTelemetry = deps.emitTelemetry ?? makeEmitTelemetry({ repo });
|
|
1701
1709
|
lanes = { adopt: null, runOne: (n, ln, ctx = {}) => runCore({ issue: n, repo, lane: ln, laneOverride, ...(ctx.placement ? { placement: { ...ctx.placement, mode: execMode() } } : {}), ...(ctx.recover ? { recover: ctx.recover } : {}), branches, cwd, dir, io: leanIo, agentId: `orch_${process.pid}_${n}`, emitTelemetry, batchId: batch.id,
|
|
1702
|
-
|
|
1710
|
+
...(readExecution(cwd)?.accountRotator ? { accountRotator: readExecution(cwd).accountRotator } : {}), // #5094
|
|
1711
|
+
admission: readAdmission(cwd), heavy: readHeavy(cwd), ...readCoreLimits(cwd), ...readEnvConfig(cwd), ...(readMergeWait(cwd) ? { mergeWait: readMergeWait(cwd) } : {}), ...(readConformanceMode(cwd) ? { conformance: readConformanceMode(cwd) } : {}), ...(readConformanceKinds(cwd) ? { conformanceKinds: readConformanceKinds(cwd) } : {}), ...(readConformanceMutation(cwd) ? { conformanceMutation: "report" } : {}), ...(readContextPack(cwd) ? { contextPack: readContextPack(cwd) } : {}), ...(readReviewIncomplete(cwd) ? { reviewIncomplete: readReviewIncomplete(cwd) } : {}), ...(readLaneInstructions(cwd) ? { laneInstructions: readLaneInstructions(cwd) } : {}), ...(readReproTest(cwd) ? { reproTest: readReproTest(cwd) } : {}), ...(readExplore(cwd) ? { explore: readExplore(cwd) } : {}), ...(readExperiment(cwd) ? { experiment: readExperiment(cwd) } : {}), light: readLight(cwd), ...(readCloseParent(cwd) ? { closeParent: true } : {}), ...(readReviewTrigger(cwd) ? { reviewTrigger: readReviewTrigger(cwd) } : {}), ...(readVerify(cwd) ? { verify: readVerify(cwd) } : {}), ...readPrecheckOptions(cwd), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model", cwd) ? { reviewerModel: readLeanSetting("reviewer_model", cwd) } : {}), ...(readFixModel(cwd) ? { fixModel: readFixModel(cwd) } : {}), ...(readBuilderRouting(cwd) ? { builderRouting: readBuilderRouting(cwd) } : {}), ...(readVerifier(cwd) ? { verifier: readVerifier(cwd) } : {}), ...(readHostCaps(cwd) ? { hostCaps: readHostCaps(cwd) } : {}), ...readRelayCaps(), ...(readSequenceRegenerate(cwd) ? { sequenceRegenerate: readSequenceRegenerate(cwd) } : {}), ...(readBaseMerge(cwd) ? { baseMerge: readBaseMerge(cwd) } : {}), ...(readFollowupsDefaultMilestone(cwd) ? { followupsDefaultMilestone: readFollowupsDefaultMilestone(cwd) } : {}), ...(readFollowupsRollupLabel(cwd) ? { followupsRollupLabel: readFollowupsRollupLabel(cwd) } : {}) }) };
|
|
1703
1712
|
} else {
|
|
1704
1713
|
const laneScript = deps.laneScript ?? fileURLToPath(new URL("./forgedock.mjs", import.meta.url));
|
|
1705
1714
|
// #4690: lanes learn K (the default MemoryMax is derived from it); one line says so when no scope caps them.
|
|
@@ -1736,7 +1745,8 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1736
1745
|
const { makeLedger } = await import("./lean/ledger.mjs");
|
|
1737
1746
|
const ledgerFor = deps.sandboxLedger ?? ((n) => makeLedger({ batch: batch.id, lane: `sandbox-${n}`, owner: `orch_${process.pid}`, issue: n }));
|
|
1738
1747
|
const bud = exec.budget ?? {};
|
|
1739
|
-
const
|
|
1748
|
+
const holdId = execMod.remoteHoldId(provider); // #5095: the pool account's usage-limit hold key
|
|
1749
|
+
const remoteLanes = execMod.providerLanes({ provider, laneArgs, ledgerFor, env, secrets: exec.secrets, costPerMinute: bud.costPerMinute ?? 0, holdId,
|
|
1740
1750
|
...(Number.isFinite(exec.unreachableWindowMin) ? { unreachableWindowMs: exec.unreachableWindowMin * 60000 } : {}),
|
|
1741
1751
|
...(deps.now ? { now: deps.now } : {}), ...(deps.pollMs ? { pollMs: deps.pollMs } : {}),
|
|
1742
1752
|
logTail: (h) => { try { return readFileSync(batch.logFile(h.issue), "utf8").slice(-4000); } catch { return ""; } } }); // #4866: a clone failure in the lane log types sandbox.clone
|
|
@@ -1775,7 +1785,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1775
1785
|
return capSample === null && onlineSample === null ? null : { free: capSample, online: onlineSample, at: capAt };
|
|
1776
1786
|
};
|
|
1777
1787
|
remote = {
|
|
1778
|
-
capacity, max: exec.maxRemote, isRemote: () => true, place, ...(hybrid ? { fallbackLocal: true } : {}), ...(exec.fallbackLocal ? { offlineFallback: true } : {}), onBudget: bud.onBudget ?? "stop", costPerMinute: bud.costPerMinute ?? 0,
|
|
1788
|
+
capacity, max: exec.maxRemote, isRemote: () => true, place, identity: () => holdId, ...(hybrid ? { fallbackLocal: true } : {}), ...(exec.fallbackLocal ? { offlineFallback: true } : {}), onBudget: bud.onBudget ?? "stop", costPerMinute: bud.costPerMinute ?? 0,
|
|
1779
1789
|
admit: (u) => execBudget.admit(u),
|
|
1780
1790
|
onUsage: (n, sb, rec) => {
|
|
1781
1791
|
const startedAt = rec?.handle?.startedAt ?? rec?.startedAt ?? "?";
|
|
@@ -1803,7 +1813,9 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1803
1813
|
// #4190: usage-limit holds live in the host store keyed by the account the next lane would use.
|
|
1804
1814
|
const limitsMod = await import("./lean/limits.mjs");
|
|
1805
1815
|
const limits = deps.limits ?? limitsMod.openLimits(limitsMod.limitsFile(env));
|
|
1806
|
-
|
|
1816
|
+
// #5094: with an account rotator the identity is its current account, and admissions hold only when every account is exhausted.
|
|
1817
|
+
const rotator = deps.rotator !== undefined ? deps.rotator : openRotator({ kind: readExecution(cwd)?.accountRotator });
|
|
1818
|
+
const identity = deps.identity ?? (() => limitsMod.laneBackendId(env, rotator));
|
|
1807
1819
|
const maint = deps.maintenance !== undefined ? deps.maintenance : readMaintenance(cwd);
|
|
1808
1820
|
const schedMod = await import("./lean/sched.mjs");
|
|
1809
1821
|
const registry = deps.sched !== undefined ? deps.sched : schedMod.openSched(schedMod.schedDir());
|
|
@@ -1925,7 +1937,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1925
1937
|
autoChildren,
|
|
1926
1938
|
...(autoMod.addsEpicChildren(autoChildren.mode) ? { getChildren: async () => ({ list: await autoMod.readChildList({ gh: (a) => leanIo.gh(a), repo, fields: childFields ??= await autoMod.hostListFields((a) => leanIo.gh(a)) }), elsewhere: autoMod.liveElsewhere({ batchMod, root, self: batch.id, repo }) }) } : {}),
|
|
1927
1939
|
issues, parallel, prefilter, resolveLane: laneOf, store: batch.store, getParallel: batch.readParallel, getAdded: batch.readAdded, getRequeued: batch.readRequeued, getCancelled: batch.readCancel, getHeld: batch.readHeld, getGates: batch.readGates, baseWait: baseWaits.size ? baseWaitOf(baseWaits, { io, leanIo, cwd }) : null, ...lanes,
|
|
1928
|
-
breaker: orch.breaker, maxDeferrals: orch.maxDeferrals, ...(deps.pollMs ? { pollMs: deps.pollMs } : {}), ...budget, limits, identity, batchId: batch.id, drift, lifecycle, integration,
|
|
1940
|
+
breaker: orch.breaker, maxDeferrals: orch.maxDeferrals, ...(deps.pollMs ? { pollMs: deps.pollMs } : {}), ...budget, limits, identity, rotator, batchId: batch.id, drift, lifecycle, integration,
|
|
1929
1941
|
...(registry ? { sched: { registry, batch: batch.id, repo, cap: liveLaneCap(orch.globalParallel ?? null, { cfg: readAdmission(cwd) }), exclusive: true }, priorityOf: async (n) => schedMod.priorityFromLabels(await labelNames(n)) } : {}),
|
|
1930
1942
|
...(remote ? { remote } : {}),
|
|
1931
1943
|
// #4267: mechanical needs-human outcomes are recovered in the batch; every runner decision is a FORGE:DECISION.
|
|
@@ -2202,13 +2214,15 @@ export async function poolFromCli(argv, deps = {}) {
|
|
|
2202
2214
|
export async function limitsFromCli(argv, deps = {}) {
|
|
2203
2215
|
const limitsMod = await import("./lean/limits.mjs");
|
|
2204
2216
|
const limits = deps.limits ?? limitsMod.openLimits(limitsMod.limitsFile());
|
|
2205
|
-
const
|
|
2217
|
+
const rotator = deps.rotator !== undefined ? deps.rotator : openRotator({ kind: readExecution(process.cwd())?.accountRotator }); // #5094
|
|
2218
|
+
const current = (deps.identity ?? (() => limitsMod.laneBackendId(process.env, rotator)))();
|
|
2206
2219
|
const usage = "usage: forgedock limits show | forgedock limits clear <id>|--all";
|
|
2207
2220
|
const [sub, arg] = argv;
|
|
2208
2221
|
if (!sub || sub === "show") {
|
|
2209
2222
|
const holds = limits.list();
|
|
2210
2223
|
if (!holds.length) console.log(`no usage-limit holds (this account: ${current})`);
|
|
2211
2224
|
for (const h of holds) console.log(`${h.id === current ? "*" : " "} ${h.id} until ${new Date(h.until).toISOString()} source ${h.source ?? "-"}`);
|
|
2225
|
+
for (const a of rotator?.accounts ?? []) console.log(` ${rotator.kind} account ${a.n} (${rotator.accountIdentity(a.n)}): 5h ${a.fiveHourPct}% resets ${new Date(a.fiveHourResetMs).toISOString()}`);
|
|
2212
2226
|
return { holds, current };
|
|
2213
2227
|
}
|
|
2214
2228
|
if (sub === "clear") {
|
|
@@ -247,6 +247,26 @@ describe("conformance gate end-to-end (real git, disposable repo)", { skip: !HAV
|
|
|
247
247
|
assert.equal(engine.self_check, "red");
|
|
248
248
|
assert.deepEqual(o.log.filter((e) => e.event === "SELF_CHECK_MISSING").map((e) => e.state), ["red"]);
|
|
249
249
|
});
|
|
250
|
+
// #5087 (R2/R6): the lane adds a Next.js route handler; only a declared forge.yaml topology.route_reachability may block it
|
|
251
|
+
const SIGNUP = "web/src/app/(site)/api/v1/agent/signup/pending/route.js";
|
|
252
|
+
const ROUTED = () => { const k = FULL(); k.deliverables.push({ id: "D5", path: SIGNUP, change: "signup handler", requirements: ["R1"] }); return k; };
|
|
253
|
+
const addRoute = (t) => { fix(t, ["scrape", "search", "export"]); t.write({ [SIGNUP]: "export async function GET() { return new Response(\"ok\"); }\n" }); };
|
|
254
|
+
it("#5087 without topology a route-adding lane merges as today: no route check runs, no new blocking failure", async () => {
|
|
255
|
+
const ran = [];
|
|
256
|
+
const o = await runCase({ contract: ROUTED, impl: addRoute }, "enforce", { runCommand: async (a) => { ran.push(a.command); return runCheckCommand(a); } });
|
|
257
|
+
merged(o);
|
|
258
|
+
assert.ok(!ran.some((c) => c.includes("/api/v1/agent/signup/pending")), "no route reachability command ran");
|
|
259
|
+
assert.ok(!o.log.some((e) => e.event === "PRECHECK" && (e.failures ?? []).some((f) => f.kind === "route_unreachable")));
|
|
260
|
+
});
|
|
261
|
+
it("#5087 a declared topology.route_reachability that rejects the new route blocks before merge (#38241 fixture)", async () => {
|
|
262
|
+
const ingress = path.join(tmpDir("ingress-"), "check-ingress.sh");
|
|
263
|
+
fs.writeFileSync(ingress, "for p in \"$@\"; do case \"$p\" in /api/v1/batch*) ;; /api/v1/*) echo \"unreachable: $p -> api\" >&2; exit 1 ;; esac; done\n");
|
|
264
|
+
const o = await runCase({ contract: ROUTED, impl: addRoute }, "enforce", { runCommand: runCheckCommand, opts: { routeReachability: `bash ${ingress}`, verify: { maxRounds: 0 } } });
|
|
265
|
+
assert.equal(o.staging.search, false, "staging untouched");
|
|
266
|
+
assert.equal(o.ret.terminalReason, "needs-human"); assert.equal(o.pr, undefined, "no PR");
|
|
267
|
+
assert.ok(o.log.some((e) => e.event === "PRECHECK" && e.failures.includes("conformance.unimplemented")));
|
|
268
|
+
assert.match(o.ret.detail, /topology\.route_reachability[\s\S]*\/api\/v1\/agent\/signup\/pending[\s\S]*unreachable: \/api\/v1\/agent\/signup\/pending -> api/);
|
|
269
|
+
});
|
|
250
270
|
it("6 a generic PASS over an incomplete implementation never merges (review.incomplete)", async () => {
|
|
251
271
|
const o = await runCase({ contract: FULL, impl: (t) => fix(t, ["scrape", "export"]), generic: true });
|
|
252
272
|
blocked(o, "review.incomplete"); assert.deepEqual([o.counts.builder, o.counts.reviewer], [2, 2]);
|
package/bin/lean/conformance.mjs
CHANGED
|
@@ -266,6 +266,28 @@ export function tmpdirInputs(command) {
|
|
|
266
266
|
|
|
267
267
|
const tmpInputProblem = (id, p) => `${id}: reads ${p}, which the command does not create; the run TMPDIR is per run, so the engine's rerun and red-green check will not find it. Create the file inside the command (\`… > ${p} && …\`) or use a path the repo tracks`;
|
|
268
268
|
|
|
269
|
+
const E2E_WORD_RE = /\b(?:e2e|end-to-end|integration|playwright|cypress|live[- ]stack)\b/i;
|
|
270
|
+
const WEAKEN_RE = /\b(?:no|not|without|drop(?:ped|s|ping)?|skip(?:ped|s|ping)?|replac\w+|downgrad\w+|instead of|rather than|in lieu of|swap\w*)\b/i;
|
|
271
|
+
const MOCKED_RE = /\b(?:mock\w*|stub\w*|fake\w*|unit|vitest|jest|component tests?|route tests?)\b/i;
|
|
272
|
+
const PROBE_RE = /forge-probe|acceptance[- ]probe/i;
|
|
273
|
+
const NEG_PROBE_RE = /\b(?:no|not|without|never)\b[^.;]{0,30}(?:forge-probe|acceptance[- ]probe)/i;
|
|
274
|
+
/**
|
|
275
|
+
* #5087: a plan decision whose effect is to drop an e2e/integration check for mocked tests ("no Playwright e2e; integration
|
|
276
|
+
* coverage via route and component tests"). The #4726 rule keys on the `[type:e2e]` tag, which a reworded criterion
|
|
277
|
+
* escapes. Exempt when an acceptance probe (#4399) is declared: in the decision itself, or by another row of the contract.
|
|
278
|
+
* Returns the decision ids; pure.
|
|
279
|
+
*/
|
|
280
|
+
export function weakenedE2eDecisions(c) {
|
|
281
|
+
const decisions = Array.isArray(c?.decisions) ? c.decisions : [];
|
|
282
|
+
const elsewhere = (id) => JSON.stringify({ r: c?.requirements ?? [], d: c?.deliverables ?? [], k: decisions.filter((k) => k?.id !== id) });
|
|
283
|
+
return decisions.filter((k) => {
|
|
284
|
+
const choice = String(k?.choice ?? "");
|
|
285
|
+
if (!E2E_WORD_RE.test(choice) || !WEAKEN_RE.test(choice) || !MOCKED_RE.test(choice)) return false;
|
|
286
|
+
const own = `${choice} ${k?.justification ?? ""}`;
|
|
287
|
+
return !((PROBE_RE.test(own) && !NEG_PROBE_RE.test(own)) || /forge-probe/i.test(elsewhere(k?.id)));
|
|
288
|
+
}).map((k) => k.id).filter(Boolean);
|
|
289
|
+
}
|
|
290
|
+
|
|
269
291
|
export function validateContract(c, issueText, { repro = false } = {}) {
|
|
270
292
|
const problems = [];
|
|
271
293
|
if (!c || typeof c !== "object" || Array.isArray(c)) return { ok: false, problems: ["contract.json is missing or not a JSON object"] };
|
|
@@ -297,6 +319,7 @@ export function validateContract(c, issueText, { repro = false } = {}) {
|
|
|
297
319
|
if (!k?.id || blank(k?.choice)) problems.push(`decision without id or choice: ${JSON.stringify(k ?? null).slice(0, 80)}`);
|
|
298
320
|
else if (!DECISION_SOURCES.includes(k?.source)) problems.push(`${k.id}: decision source must be one of ${DECISION_SOURCES.join("|")}`);
|
|
299
321
|
}
|
|
322
|
+
for (const id of weakenedE2eDecisions(c)) problems.push(`${id}: a plan decision may not replace an e2e/integration check with mocked tests; run a real e2e check, or declare a \`forge-probe\` acceptance probe (#4399) that runs after promotion and reopens on failure`);
|
|
300
323
|
if (!Array.isArray(c.validate) || !c.validate.length) problems.push("the contract names no validate[] commands");
|
|
301
324
|
else for (const v of c.validate) {
|
|
302
325
|
if (!v?.id || blank(v?.command)) { problems.push("validate entry without id or command"); continue; }
|
|
@@ -379,6 +402,21 @@ function amendmentProblem(a, tag) {
|
|
|
379
402
|
return `unknown or incomplete amendment: ${why}: ${tag}`;
|
|
380
403
|
}
|
|
381
404
|
|
|
405
|
+
/** #5089: shape-only check of the builder's amendments (unknown kind, missing path or requirement), run before the gate so one in-lane correction can fix them. Returns the rejection strings; [] when valid. */
|
|
406
|
+
export function amendmentKindProblems(amendments) {
|
|
407
|
+
const out = [];
|
|
408
|
+
for (const raw of Array.isArray(amendments) ? amendments : []) {
|
|
409
|
+
const a = normalizeAmendment(raw);
|
|
410
|
+
if (a?.kind === UNRELATED_CI_KIND || INFO_KINDS.includes(a?.kind)) continue;
|
|
411
|
+
const tag = JSON.stringify(raw ?? null).slice(0, 400);
|
|
412
|
+
const known = AMEND_KINDS.includes(a?.kind);
|
|
413
|
+
const missing = a?.kind === "add-deliverable" ? blank(a.path)
|
|
414
|
+
: TRACKED.includes(a?.kind) || a?.kind === "amend-requirement" ? blank(a.requirement) : false;
|
|
415
|
+
if (!known || missing) out.push(amendmentProblem(a, tag));
|
|
416
|
+
}
|
|
417
|
+
return out;
|
|
418
|
+
}
|
|
419
|
+
|
|
382
420
|
/**
|
|
383
421
|
* Apply result.json amendments to the frozen contract. Kinds: add-deliverable {path}, add-requirement {requirement},
|
|
384
422
|
* defer|post-deploy {requirement: id}. Every amendment needs a why (or the engine's defaultWhy); `addedPaths` are lane-added files that count as decision anchors when a deliverable lists them. Returns {contract, applied[], toTrack[], problems[]}.
|
|
@@ -720,7 +758,7 @@ export function repoTracks(paths) {
|
|
|
720
758
|
* `ciCovered` (#4527): the validate ids accepted only because the head is CI-verified (no local result, or a missing host
|
|
721
759
|
* tool), for the trajectory; always an array.
|
|
722
760
|
*/
|
|
723
|
-
export function precheck({ contract, result, changedFiles, ignore = [], ciVerified = false, hotspots = null, repro = null, validateRuns = null, repoHas = () => false, frozen = null, checkIds = null, guardFailures = null, newOffKeys = null, substitutedRuns = null, removedPaths = [], baseFiles = [] }) {
|
|
761
|
+
export function precheck({ contract, result, changedFiles, ignore = [], ciVerified = false, hotspots = null, repro = null, validateRuns = null, repoHas = () => false, frozen = null, checkIds = null, guardFailures = null, coupledFailures = null, routeFailures = null, newOffKeys = null, substitutedRuns = null, removedPaths = [], baseFiles = [] }) {
|
|
724
762
|
const failures = [], superseded = [], ciCovered = [];
|
|
725
763
|
// #4626: a post-deploy check id must be on the default branch or added by this PR (`checkIds`: ids of both checks files; null = not resolved)
|
|
726
764
|
if (checkIds) for (const r of contract.requirements ?? []) {
|
|
@@ -785,9 +823,12 @@ export function precheck({ contract, result, changedFiles, ignore = [], ciVerifi
|
|
|
785
823
|
}
|
|
786
824
|
failures.push(...hotspotGrowth({ contract, result, hotspots, changedFiles }));
|
|
787
825
|
if (repro) failures.push(...reproFailures(contract, repro));
|
|
826
|
+
failures.push(...sourceDeliverableFailures(contract, changedFiles, ignore));
|
|
788
827
|
// #4693: a broken guard fails unless the contract carries a supersedes amendment for it
|
|
789
828
|
const retired = new Set((contract.supersedes ?? []).map((s) => s.guard));
|
|
790
829
|
for (const f of guardFailures ?? []) if (!retired.has(f.guard)) failures.push(f);
|
|
830
|
+
for (const f of coupledFailures ?? []) failures.push(f); // #5079
|
|
831
|
+
failures.push(...(routeFailures ?? [])); // #5087: topology.route_reachability, blocking when declared
|
|
791
832
|
return { ok: failures.length === 0, failures, superseded, ciCovered };
|
|
792
833
|
}
|
|
793
834
|
|
|
@@ -819,10 +860,40 @@ export function reproFailures(contract, { base, head } = {}) {
|
|
|
819
860
|
detail: `repro test ${name} (\`${t.command ?? ""}\`): the harness could not run the test on head (environment, not the change); got BASE=${base ?? "UNRUNNABLE"} HEAD=${head ?? "UNRUNNABLE"}` }];
|
|
820
861
|
}
|
|
821
862
|
const skipped = base === "SKIPPED" ? " (base not run: head did not pass)" : "";
|
|
863
|
+
// #5080: the test already passes on the base: the fix is there (a hotfix or another PR), not missing from this change
|
|
864
|
+
if (base === "RAN") return [{ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "repro_base_green",
|
|
865
|
+
detail: `repro test ${name} (\`${t.command ?? ""}\`) must fail on the base and pass on head; got BASE=RAN HEAD=RAN: it passes on the base already (the fix may already be on the base)` }];
|
|
822
866
|
return [{ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "repro_red_green",
|
|
823
867
|
detail: `repro test ${name} (\`${t.command ?? ""}\`) must fail on the base and pass on head; got BASE=${base ?? "UNRUNNABLE"} HEAD=${head}${skipped}` }];
|
|
824
868
|
}
|
|
825
869
|
|
|
870
|
+
const DOC_ONLY_RE = /\.(?:md|mdx|txt|rst|adoc)$/i;
|
|
871
|
+
const SOURCE_EXT_RE = /\.(?:m?[jt]sx?|cjs|py|go|rs|rb|java|kt|swift|php|cs|c|cc|cpp|h|hpp|sh)$/i;
|
|
872
|
+
/**
|
|
873
|
+
* #5080: a diff of test files only, for a contract that names a code deliverable (a non-test source file): the tests pin
|
|
874
|
+
* behaviour but nothing was changed, so it must not be accepted as the fix. Returns the failure rows ([] otherwise).
|
|
875
|
+
*/
|
|
876
|
+
export function sourceDeliverableFailures(contract, changedFiles = [], ignore = []) {
|
|
877
|
+
const files = changedFiles.filter((f) => !ignore.some((p) => f.startsWith(p)));
|
|
878
|
+
if (!files.length || !files.every(isTestPath)) return [];
|
|
879
|
+
const src = (contract.deliverables ?? []).map((d) => cleanPath(d.path)).filter((p) => p && !/[*?]|\/$/.test(p) && !isTestPath(p) && SOURCE_EXT_RE.test(p));
|
|
880
|
+
if (!src.length) return [];
|
|
881
|
+
return [{ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "source_deliverable_untouched",
|
|
882
|
+
detail: `the diff changes only test files (${files.slice(0, 3).join(", ")}${files.length > 3 ? ", ..." : ""}) but the contract names source deliverable(s) ${src.slice(0, 3).join(", ")}: tests that pin current behaviour are not the fix` }];
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* #5080: the repro already passes on the base (repro_base_green), the diff has no non-test source change, and the only other
|
|
887
|
+
* failure is source_deliverable_untouched: the issue is already fixed on the base, so it closes as delivered instead of
|
|
888
|
+
* needs-human. A diff that changes source keeps the conformance.unimplemented path (K3).
|
|
889
|
+
*/
|
|
890
|
+
export function alreadyFixedOnBase(failures = [], changedFiles = [], ignore = []) {
|
|
891
|
+
const files = changedFiles.filter((f) => !ignore.some((p) => f.startsWith(p)));
|
|
892
|
+
// Release review of #5103 (MAT-1): any non-test, non-doc change (css, json, yaml, sql, tf, ...) is work the lane did, never "already fixed".
|
|
893
|
+
if (files.some((f) => !isTestPath(f) && !DOC_ONLY_RE.test(f))) return false;
|
|
894
|
+
return failures.some((f) => f.kind === "repro_base_green") && failures.every((f) => f.kind === "repro_base_green" || f.kind === "source_deliverable_untouched");
|
|
895
|
+
}
|
|
896
|
+
|
|
826
897
|
const EXEMPT_RE = /hotspot-exempt:\s*\S/i;
|
|
827
898
|
/**
|
|
828
899
|
* forge#4195: a changed file already over `file_lines` (not generated, vendored or glob-excluded; forge#4519: a
|
|
@@ -1130,10 +1201,10 @@ export function formatShadowSummary(rows) {
|
|
|
1130
1201
|
return `conformance: ${sh.length} shadow lane${sh.length === 1 ? "" : "s"}, would block ${would} (${pct(would)}), false-block ${falseB} (${pct(falseB)}: would-block on an incomplete/inconsistent/unparseable review); risk-path enforced: ${risk}${kinds.length ? `; per kind: ${kinds.join(", ")}` : ""}`;
|
|
1131
1202
|
}
|
|
1132
1203
|
|
|
1133
|
-
/** #4664: follow-up classes, one issue each per parent (scripts/file-deferred-followups.sh); post-deploy is one rollup per release lane (#4724). */
|
|
1134
|
-
export const FOLLOWUP_CLASSES = ["engineering", "post-deploy", "external", "ops"];
|
|
1204
|
+
/** #4664: follow-up classes, one issue each per parent (scripts/file-deferred-followups.sh); post-deploy is one rollup per release lane (#4724), external one aggregate per batch and host a `host-task` (#5078). */
|
|
1205
|
+
export const FOLLOWUP_CLASSES = ["engineering", "post-deploy", "external", "ops", "host"];
|
|
1135
1206
|
/** #4724: the bucket every deferred row gets at contract time. `date:<YYYY-MM-DD>` and `owner:<action>` carry a value. */
|
|
1136
|
-
export const BUCKETS = Object.freeze(["post-deploy", "verify-now", "date", "owner", "new-issue"]);
|
|
1207
|
+
export const BUCKETS = Object.freeze(["post-deploy", "verify-now", "date", "owner", "new-issue", "host"]);
|
|
1137
1208
|
const DATE_BUCKET_RE = /^date:(\d{4})-(\d{2})-(\d{2})$/;
|
|
1138
1209
|
/** The kind of a bucket (`date:2026-12-01` -> `date`), or null when absent or unknown. */
|
|
1139
1210
|
export const bucketKind = (b) => { const k = String(b ?? "").split(":")[0]; return BUCKETS.includes(k) ? k : null; };
|
|
@@ -1143,19 +1214,38 @@ export function bucketProblems(r) {
|
|
|
1143
1214
|
if (!TRACKED.includes(r?.disposition)) return b == null ? [] : ["bucket applies only to a defer or post-deploy requirement"];
|
|
1144
1215
|
if (b == null || b === "") return [`${r.disposition} needs a bucket (${BUCKETS.join("|")}; \`date:<YYYY-MM-DD>\`, \`owner:<action>\`)`];
|
|
1145
1216
|
const k = bucketKind(b), out = [];
|
|
1146
|
-
if (!k || (k !== "date" && k !== "owner" && b !== k)) out.push(`bucket '${b}' must be post-deploy, verify-now, date:<YYYY-MM-DD>, owner:<action
|
|
1217
|
+
if (!k || (k !== "date" && k !== "owner" && b !== k)) out.push(`bucket '${b}' must be post-deploy, verify-now, date:<YYYY-MM-DD>, owner:<action>, new-issue or host`);
|
|
1147
1218
|
else if (k === "date") {
|
|
1148
1219
|
const m = DATE_BUCKET_RE.exec(String(b)), d = m && new Date(Date.UTC(+m[1], +m[2] - 1, +m[3]));
|
|
1149
1220
|
if (!m || d.getUTCFullYear() !== +m[1] || d.getUTCMonth() !== +m[2] - 1 || d.getUTCDate() !== +m[3]) out.push(`bucket '${b}' must be an absolute date:<YYYY-MM-DD>, not a relative wait`);
|
|
1150
1221
|
} else if (k === "owner" && blank(String(b).slice(6))) out.push("bucket owner needs the action: owner:<action>");
|
|
1151
|
-
if (r.disposition === "post-deploy" && (k === "owner" || k === "new-issue")) out.push(`a ${k} item is never post-deploy: use disposition defer`);
|
|
1222
|
+
if (r.disposition === "post-deploy" && (k === "owner" || k === "new-issue" || k === "host")) out.push(`a ${k} item is never post-deploy: use disposition defer`);
|
|
1223
|
+
if (k === "owner" && ownerRoute(r) === "host") out.push("read-only production data or capture is not an owner action: use bucket host");
|
|
1152
1224
|
if (k === "owner" && r.class != null && r.class !== "external" && r.class !== "ops") out.push("an owner bucket belongs in class external or ops, never post-deploy");
|
|
1153
1225
|
return out;
|
|
1154
1226
|
}
|
|
1155
|
-
/**
|
|
1227
|
+
/** #5078: post-promotion / production-verify / rollout text (the rollup) and read-only production data or capture (the host: the prod_monitor signals of execution.mjs plus a read-only prod query). Mirrored in scripts/file-deferred-followups.sh. */
|
|
1228
|
+
const POST_PROMOTION_RE = /(?:after|post)[- ]?(?:promotion|release|deploy)|in production after|production[- ]verif|promot(?:e|ion)|rollout|purge|deploy[- ]time/i;
|
|
1229
|
+
export const READONLY_PROD_RE = /\bssh\s+[\w.-]*-monitor\b|\bobs-psql\b|\bprod(?:uction)?[ _-]monitor\b|\bread[- ]only\b[^.]{0,40}\bprod(?:uction)?\b|\bprod(?:uction)?\b[^.]{0,30}\b(?:query|queries|capture|telemetry|logs?)\b/i;
|
|
1230
|
+
/** An item that truly needs the outside world (or a write to production) stays external even when it also reads production. */
|
|
1231
|
+
export const EXTERNAL_NEED_RE = /credential|secret|api[- ]?key|token|password|passphrase|ssh[- ]?keys?\b|private[- ]key|\blogin\b|\bsudo\b|spend|budget|billing|purchase|payment|account|third[- ]party|vendor|customer|contract sign|legal|\b(?:delete|drop|truncate|wipe|destroy|modify|migrate|write)\b/i;
|
|
1232
|
+
/**
|
|
1233
|
+
* #5078: where an owner deferral goes. `owner:orchestrator`, `owner:promote:*` or post-promotion text -> post-deploy (the
|
|
1234
|
+
* rollup); read-only production data or capture (with no credential/spend/account/third-party/customer need) -> host; else external (credentials, spend, an account, a third party or a customer).
|
|
1235
|
+
*/
|
|
1236
|
+
export function ownerRoute(r) {
|
|
1237
|
+
const action = String(r?.bucket ?? "").replace(/^owner:/, "");
|
|
1238
|
+
if (/^(?:orchestrator$|promote(?::|$))/i.test(action)) return "post-deploy";
|
|
1239
|
+
const text = `${action} ${r?.why ?? ""}`;
|
|
1240
|
+
// Release review #5103 (BUG-3): a credential/spend/write need is external even when it also names a rollout or release.
|
|
1241
|
+
if (POST_PROMOTION_RE.test(text) && !EXTERNAL_NEED_RE.test(text)) return "post-deploy";
|
|
1242
|
+
return READONLY_PROD_RE.test(text) && !EXTERNAL_NEED_RE.test(text) ? "host" : "external";
|
|
1243
|
+
}
|
|
1244
|
+
/** The follow-up class of a requirement: owner -> ownerRoute (ops kept), host -> host, new-issue -> engineering, other buckets post-deploy; else its `class`, else post-deploy for that disposition, else engineering. */
|
|
1156
1245
|
export function followupClass(r) {
|
|
1157
1246
|
const k = bucketKind(r?.bucket);
|
|
1158
|
-
if (k === "owner") return r?.class === "ops" ? "ops" :
|
|
1247
|
+
if (k === "owner") return r?.class === "ops" ? "ops" : ownerRoute(r);
|
|
1248
|
+
if (k === "host") return "host";
|
|
1159
1249
|
if (k === "new-issue") return r?.class && r.class !== "post-deploy" && FOLLOWUP_CLASSES.includes(r.class) ? r.class : "engineering";
|
|
1160
1250
|
if (k) return "post-deploy";
|
|
1161
1251
|
return FOLLOWUP_CLASSES.includes(r?.class) ? r.class : r?.disposition === "post-deploy" ? "post-deploy" : "engineering";
|
|
@@ -1175,7 +1265,7 @@ export function followupItems(reqs, { now = Date.now() } = {}) {
|
|
|
1175
1265
|
const k = bucketKind(r.bucket);
|
|
1176
1266
|
const tag = k ? `[bucket:${k}] ` : "";
|
|
1177
1267
|
const due = k === "date" ? `due:${String(r.bucket).slice(5)} from:${new Date(now).toISOString().slice(0, 10)} ` : "";
|
|
1178
|
-
const head = k === "owner" ? `owner: ${flat(String(r.bucket).slice(6))}. ` : k && k !== "new-issue" ? "post-deploy: " : `${r.disposition}: `;
|
|
1268
|
+
const head = k === "owner" ? `owner: ${flat(String(r.bucket).slice(6))}. ` : k === "host" ? "host: " : k && k !== "new-issue" ? "post-deploy: " : `${r.disposition}: `;
|
|
1179
1269
|
return { id: r.id, class: followupClass(r), key: flat(`${r.id}: ${r.quote}`).slice(0, 240).trim(), why: flat(`${head}${tag}${check(r)}${due}${r.why ?? ""}`).slice(0, 400).trim() };
|
|
1180
1270
|
});
|
|
1181
1271
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
4
|
import { describe, it } from "node:test";
|
|
5
5
|
import assert from "node:assert/strict";
|
|
6
|
-
import { removedNamedPaths, removedPathsOf, shellSyntaxError, unrunnableValidate, noopCommand, substituteVerdict, describesUnrunnableCommand, vitestQuietFlag, normalizeRunnerCommand, cliUsageError, validateContract, applyAmendments, precheck, hotspotGrowth, reproFailures, isBugIssue, parseReview, checkCompleteness, decide, closePlan, isInvestigation, formatShadowSummary, followupItems, inIssue, isTestPath, normalizeEvidencePath, mergeReviewRows, reviewSkeleton, missingIds, resolveEvidencePath, movingBaseDiff, scopeText, evidencePathLine, evidencePathOnly, explainFailures, resolveForgedockPaths, missingTool, repoTracks, tmpdirInputs, isScopeCheck, isPureScopeCheck, dropScopeValidates, tagOutOfBand, outOfBandRequirements, isOutOfBand, followupMap, VERIFY_MODES, OUT_OF_BAND_RE, FOLLOWUP_CLASSES, followupClass, BUCKETS, bucketKind, bucketProblems, bucketTally, isE2eCommand, e2eVerified, impliedDocDeliverables, applyValidateRecipes, deferChecklessPostDeploy, substituteShellProblem, reproEnvMiss, ORCHESTRATOR_BUCKET } from "./conformance.mjs";
|
|
6
|
+
import { removedNamedPaths, removedPathsOf, shellSyntaxError, unrunnableValidate, noopCommand, substituteVerdict, describesUnrunnableCommand, vitestQuietFlag, normalizeRunnerCommand, cliUsageError, validateContract, applyAmendments, amendmentKindProblems, precheck, hotspotGrowth, reproFailures, isBugIssue, parseReview, checkCompleteness, decide, closePlan, isInvestigation, formatShadowSummary, followupItems, inIssue, isTestPath, normalizeEvidencePath, mergeReviewRows, reviewSkeleton, missingIds, resolveEvidencePath, movingBaseDiff, scopeText, evidencePathLine, evidencePathOnly, explainFailures, resolveForgedockPaths, missingTool, repoTracks, tmpdirInputs, isScopeCheck, isPureScopeCheck, dropScopeValidates, tagOutOfBand, outOfBandRequirements, isOutOfBand, followupMap, VERIFY_MODES, OUT_OF_BAND_RE, FOLLOWUP_CLASSES, followupClass, BUCKETS, bucketKind, bucketProblems, bucketTally, isE2eCommand, e2eVerified, impliedDocDeliverables, applyValidateRecipes, deferChecklessPostDeploy, substituteShellProblem, reproEnvMiss, ORCHESTRATOR_BUCKET, ownerRoute, weakenedE2eDecisions } from "./conformance.mjs";
|
|
7
7
|
|
|
8
8
|
const ISSUE = "## Requirements\n1. The scrape route must release the spend reservation on 429.\n2. The search route must release the spend reservation on 429.\n\n- [ ] Billing pricing is unchanged.";
|
|
9
9
|
const Q1 = "The scrape route must release the spend reservation on 429.";
|
|
@@ -649,7 +649,7 @@ describe("repro_test on bug lanes (#4271)", () => {
|
|
|
649
649
|
it("red on base and green on head passes; anything else fails the precheck as conformance.unimplemented", () => {
|
|
650
650
|
const k = { ...C, repro_test: RT };
|
|
651
651
|
assert.deepEqual(precheck({ contract: k, result: res, changedFiles: files, repro: { base: "FAILED", head: "RAN" } }), { ok: true, failures: [], superseded: [], ciCovered: [] });
|
|
652
|
-
for (const repro of [{ base: "
|
|
652
|
+
for (const repro of [{ base: "SKIPPED", head: "FAILED" }, { base: "UNRUNNABLE", head: "RAN" }, { base: "SKIPPED", head: "RAN_NOTHING" }]) {
|
|
653
653
|
const pc = precheck({ contract: k, result: res, changedFiles: files, repro });
|
|
654
654
|
assert.deepEqual(pc.failures.map((f) => [f.code, f.kind]), [["conformance.unimplemented", "repro_red_green"]], JSON.stringify(repro));
|
|
655
655
|
assert.match(pc.failures[0].detail, new RegExp(`BASE=${repro.base} HEAD=${repro.head}`));
|
|
@@ -657,6 +657,26 @@ describe("repro_test on bug lanes (#4271)", () => {
|
|
|
657
657
|
assert.match(reproFailures(k, { base: "SKIPPED", head: "FAILED" })[0].detail, /HEAD=FAILED \(base not run: head did not pass\)$/);
|
|
658
658
|
assert.doesNotMatch(reproFailures(k, { base: "RAN", head: "RAN" })[0].detail, /base not run/);
|
|
659
659
|
});
|
|
660
|
+
it("#5080 repro_base_green: BASE=RAN HEAD=RAN is its own kind, and a test-only diff with a source deliverable is never the fix", () => {
|
|
661
|
+
const k = { ...C, repro_test: RT };
|
|
662
|
+
const green = precheck({ contract: k, result: res, changedFiles: files, repro: { base: "RAN", head: "RAN" } });
|
|
663
|
+
assert.deepEqual(green.failures.map((f) => [f.code, f.kind]), [["conformance.unimplemented", "repro_base_green"]]);
|
|
664
|
+
assert.match(green.failures[0].detail, /must fail on the base and pass on head; got BASE=RAN HEAD=RAN/);
|
|
665
|
+
const testOnly = ["test/leak.test.js"];
|
|
666
|
+
const pc = precheck({ contract: k, result: res, changedFiles: testOnly, repro: { base: "RAN", head: "RAN" } });
|
|
667
|
+
assert.deepEqual(pc.failures.map((f) => f.kind), ["repro_base_green", "source_deliverable_untouched"]);
|
|
668
|
+
assert.equal(alreadyFixedOnBase(pc.failures, testOnly), true);
|
|
669
|
+
assert.equal(alreadyFixedOnBase(green.failures, testOnly), true);
|
|
670
|
+
assert.equal(alreadyFixedOnBase(green.failures, ["src/leak.js", "test/leak.test.js"]), false, "a source change keeps conformance.unimplemented (K3)");
|
|
671
|
+
assert.equal(alreadyFixedOnBase(green.failures, ["docs/a.md", ".forge-lean/x.json"], [".forge-lean/"]), true);
|
|
672
|
+
assert.equal(alreadyFixedOnBase([{ kind: "repro_base_green" }, { kind: "validate_failed" }]), false);
|
|
673
|
+
assert.equal(alreadyFixedOnBase([{ kind: "source_deliverable_untouched" }]), false);
|
|
674
|
+
const only = precheck({ contract: k, result: res, changedFiles: testOnly, repro: { base: "FAILED", head: "RAN" } });
|
|
675
|
+
assert.deepEqual(only.failures.map((f) => f.kind), ["source_deliverable_untouched"]);
|
|
676
|
+
assert.deepEqual(sourceDeliverableFailures(k, files), []);
|
|
677
|
+
assert.deepEqual(sourceDeliverableFailures({ deliverables: [{ id: "D1", path: "docs/a.md" }, { id: "D2", path: "test/leak.test.js" }] }, testOnly), []);
|
|
678
|
+
assert.deepEqual(sourceDeliverableFailures(k, ["test/leak.test.js", ".forge-lean/x.json"], [".forge-lean/"]).map((f) => f.kind), ["source_deliverable_untouched"]);
|
|
679
|
+
});
|
|
660
680
|
it("a harness UNRUNNABLE on head is env.repro_unrunnable, not conformance.unimplemented (#4334)", () => {
|
|
661
681
|
const k = { ...C, repro_test: RT };
|
|
662
682
|
for (const repro of [{ base: "SKIPPED", head: "UNRUNNABLE" }, { base: "UNRUNNABLE", head: "UNRUNNABLE" }, {}]) {
|
|
@@ -930,7 +950,7 @@ describe("post-deploy check ids (#4626)", () => {
|
|
|
930
950
|
assert.equal(cls({ disposition: "post-deploy", check: "x" }), "post-deploy");
|
|
931
951
|
assert.equal(cls({ disposition: "defer", class: "external" }), "external");
|
|
932
952
|
assert.equal(cls({ disposition: "defer", class: "bogus" }), "engineering");
|
|
933
|
-
assert.deepEqual(FOLLOWUP_CLASSES, ["engineering", "post-deploy", "external", "ops"]);
|
|
953
|
+
assert.deepEqual(FOLLOWUP_CLASSES, ["engineering", "post-deploy", "external", "ops", "host"]);
|
|
934
954
|
assert.equal(followupClass({ disposition: "post-deploy" }), "post-deploy"); assert.equal(followupClass({ class: "ops" }), "ops");
|
|
935
955
|
const v = validateContract({ requirements: [{ id: "R1", quote: Q1, disposition: "defer", why: "w", class: "bogus" }], deliverables: [], preserve: [], validate: [{ id: "V1", command: "true" }] }, Q1);
|
|
936
956
|
assert.ok(v.problems.some((x) => /R1: class must be one of/.test(x)), v.problems.join(";"));
|
|
@@ -942,7 +962,7 @@ describe("post-deploy check ids (#4626)", () => {
|
|
|
942
962
|
it("#4724 a deferred row needs exactly one valid bucket; owner actions are never post-deploy; dates are absolute", () => {
|
|
943
963
|
const contract = (r) => ({ requirements: [{ id: "R1", quote: Q1, why: "w", ...r }], deliverables: [], preserve: [], validate: [{ id: "V1", command: "true" }] });
|
|
944
964
|
const probs = (r) => validateContract(contract(r), Q1).problems.filter((x) => /^R1: .*(bucket|never post-deploy|owner)/.test(x));
|
|
945
|
-
assert.deepEqual(BUCKETS, ["post-deploy", "verify-now", "date", "owner", "new-issue"]);
|
|
965
|
+
assert.deepEqual(BUCKETS, ["post-deploy", "verify-now", "date", "owner", "new-issue", "host"]);
|
|
946
966
|
assert.equal(probs({ disposition: "defer" }).length, 1); // missing bucket
|
|
947
967
|
for (const bucket of ["post-deploy", "verify-now", "new-issue", "date:2026-12-01", "owner:rotate the key"]) assert.deepEqual(probs({ disposition: "defer", bucket }), [], bucket);
|
|
948
968
|
for (const bucket of ["date:5 weeks after deploy", "date:2026-02-30", "date", "owner:", "owner", "later", "post-deploy:x"]) assert.equal(probs({ disposition: "defer", bucket }).length, 1, bucket);
|
|
@@ -965,6 +985,29 @@ describe("post-deploy check ids (#4626)", () => {
|
|
|
965
985
|
assert.equal(one({ bucket: "new-issue" }).why, "defer: [bucket:new-issue] w");
|
|
966
986
|
assert.equal(one({}).why, "defer: w"); // an unbucketed (older) row is unchanged
|
|
967
987
|
});
|
|
988
|
+
it("#5078 owner is narrowed: post-promotion -> rollup, read-only prod -> host, credentials/spend -> external", () => {
|
|
989
|
+
const d = (bucket, why = "w") => ({ disposition: "defer", bucket, why });
|
|
990
|
+
assert.equal(ownerRoute(d(ORCHESTRATOR_BUCKET)), "post-deploy");
|
|
991
|
+
assert.equal(followupClass(d("owner:orchestrator", "post-deploy action with no registered check")), "post-deploy");
|
|
992
|
+
assert.equal(followupClass(d("owner:promote:new-nav", "activate the flag")), "post-deploy");
|
|
993
|
+
assert.equal(followupClass(d("owner:ops", "purge the CDN after release")), "post-deploy");
|
|
994
|
+
assert.equal(followupClass(d("owner:qa", "verify in production after promotion")), "post-deploy");
|
|
995
|
+
assert.equal(followupClass(d("owner:obs-psql", "capture the read-only production query counts")), "host");
|
|
996
|
+
assert.equal(followupClass(d("owner:ssh ovh-monitor", "tail the logs")), "host");
|
|
997
|
+
assert.equal(followupClass(d("owner:stripe", "needs live Stripe credentials")), "external");
|
|
998
|
+
assert.equal(followupClass(d("owner:finance", "approve the spend")), "external");
|
|
999
|
+
assert.equal(followupClass(d("owner:ops", "needs a customer account to read production data")), "external");
|
|
1000
|
+
assert.equal(followupClass({ ...d("owner:obs-psql", "read prod data"), class: "ops" }), "ops");
|
|
1001
|
+
assert.equal(followupClass(d("host")), "host");
|
|
1002
|
+
assert.deepEqual(bucketProblems(d("host")), []);
|
|
1003
|
+
assert.match(bucketProblems({ disposition: "post-deploy", check: "a", bucket: "host" }).join(";"), /never post-deploy/);
|
|
1004
|
+
assert.match(bucketProblems(d("owner:obs-psql", "capture production query counts")).join(";"), /use bucket host/);
|
|
1005
|
+
assert.deepEqual(bucketProblems(d("owner:stripe", "needs live credentials")), []);
|
|
1006
|
+
assert.deepEqual(bucketProblems(d(ORCHESTRATOR_BUCKET, "manual prod action")), []);
|
|
1007
|
+
const [h, o] = followupItems([{ id: "R1", quote: Q1, ...d("host", "count rows") }, { id: "R2", quote: Q1, why: "w", ...d("owner:orchestrator") }]);
|
|
1008
|
+
assert.equal(h.why, "host: [bucket:host] count rows"); assert.equal(h.class, "host");
|
|
1009
|
+
assert.equal(o.class, "post-deploy"); assert.match(o.why, /^owner: orchestrator\. \[bucket:owner\]/);
|
|
1010
|
+
});
|
|
968
1011
|
it("#4724 bucketTally counts deferred items per bucket kind; an amendment may carry the bucket", () => {
|
|
969
1012
|
assert.deepEqual(bucketTally([{ bucket: "date:2026-12-01" }, { bucket: "date:2026-12-02" }, { bucket: "owner:x" }, {}]), { date: 2, owner: 1, none: 1 });
|
|
970
1013
|
const c = { requirements: [{ id: "R1", quote: Q1, disposition: "deliver" }], deliverables: [], preserve: [], validate: [] };
|
|
@@ -1116,6 +1159,7 @@ describe("per-kind modes and requirement evidence", () => {
|
|
|
1116
1159
|
});
|
|
1117
1160
|
|
|
1118
1161
|
import { mutationTargets, mutationFailures, runMutationCheck, MUTATION_SURVIVED } from "./conformance.mjs";
|
|
1162
|
+
import { alreadyFixedOnBase, sourceDeliverableFailures } from "./conformance.mjs";
|
|
1119
1163
|
|
|
1120
1164
|
describe("mutation check (#4913)", () => {
|
|
1121
1165
|
const c = {
|
|
@@ -1267,3 +1311,76 @@ describe("#5020 freeze-time environment defects", () => {
|
|
|
1267
1311
|
assert.equal(r.contract.validate[0].command, "poetry run pytest");
|
|
1268
1312
|
});
|
|
1269
1313
|
});
|
|
1314
|
+
|
|
1315
|
+
describe("#5087 a plan decision may not weaken an e2e/integration criterion to mocked tests", () => {
|
|
1316
|
+
const K2 = { id: "K2", choice: "No Playwright e2e; integration coverage via route and component tests with mocked getServerSession and fetch", justification: "web/e2e needs a live stack", source: "precedent" };
|
|
1317
|
+
it("rejects the K2 wording of AlterLab#38180 at contract validation", () => {
|
|
1318
|
+
assert.deepEqual(weakenedE2eDecisions({ ...C, decisions: [K2] }), ["K2"]);
|
|
1319
|
+
const v = validateContract({ ...C, decisions: [K2] }, ISSUE);
|
|
1320
|
+
assert.equal(v.ok, false);
|
|
1321
|
+
assert.ok(v.problems.some((p) => p.startsWith("K2:") && p.includes("forge-probe")), v.problems.join("|"));
|
|
1322
|
+
});
|
|
1323
|
+
it("accepts it when an acceptance probe is declared, in the decision or elsewhere in the contract", () => {
|
|
1324
|
+
assert.deepEqual(weakenedE2eDecisions({ ...C, decisions: [{ ...K2, justification: "a forge-probe acceptance probe runs the flow after promotion" }] }), []);
|
|
1325
|
+
assert.deepEqual(weakenedE2eDecisions({ ...C, deliverables: [...C.deliverables, { id: "D9", path: "x.md", change: "declare a forge-probe block" }], decisions: [K2] }), []);
|
|
1326
|
+
assert.deepEqual(weakenedE2eDecisions({ ...C, decisions: [{ ...K2, justification: "no acceptance probe" }] }), ["K2"]);
|
|
1327
|
+
});
|
|
1328
|
+
it("leaves unrelated decisions and contracts without decisions alone", () => {
|
|
1329
|
+
assert.deepEqual(weakenedE2eDecisions(C), []);
|
|
1330
|
+
assert.deepEqual(weakenedE2eDecisions({ ...C, decisions: [{ id: "K1", choice: "Run the Playwright e2e suite against the live stack", source: "doc" }, { id: "K3", choice: "Use unit tests with mocks for the parser", source: "spec" }] }), []);
|
|
1331
|
+
assert.deepEqual(validateContract({ ...C, decisions: [{ id: "K1", choice: "copy the recipes pattern", source: "precedent" }] }, ISSUE), { ok: true, problems: [] });
|
|
1332
|
+
});
|
|
1333
|
+
it("precheck appends routeFailures as blocking failures; absent keeps today's behaviour", () => {
|
|
1334
|
+
const res = { requirements: [{ id: "R1", status: "implemented" }, { id: "R2", status: "implemented" }], validation: [{ id: "V1", exit: 0 }] };
|
|
1335
|
+
assert.equal(precheck({ contract: C, result: res, changedFiles: files }).ok, true);
|
|
1336
|
+
assert.equal(precheck({ contract: C, result: res, changedFiles: files, routeFailures: [] }).ok, true);
|
|
1337
|
+
const pc = precheck({ contract: C, result: res, changedFiles: files, routeFailures: [{ code: "conformance.unimplemented", kind: "route_unreachable", detail: "x" }] });
|
|
1338
|
+
assert.equal(pc.ok, false);
|
|
1339
|
+
assert.deepEqual(pc.failures.map((f) => f.kind), ["route_unreachable"]);
|
|
1340
|
+
});
|
|
1341
|
+
it("#4726 [type:e2e] behaviour is unchanged", () => {
|
|
1342
|
+
assert.equal(isE2eCommand("node --test test/leak.test.js"), false);
|
|
1343
|
+
assert.equal(isE2eCommand("npx playwright test"), true);
|
|
1344
|
+
});
|
|
1345
|
+
});
|
|
1346
|
+
|
|
1347
|
+
describe("amendmentKindProblems (#5089)", () => {
|
|
1348
|
+
it("flags the invented deliverable_change kind and names the valid kinds", () => {
|
|
1349
|
+
const p = amendmentKindProblems([{ kind: "deliverable_change", requirement: "R1", why: "no Linux build" }]);
|
|
1350
|
+
assert.equal(p.length, 1);
|
|
1351
|
+
assert.match(p[0], /unknown kind 'deliverable_change'; valid kinds are .*defer/);
|
|
1352
|
+
});
|
|
1353
|
+
it("accepts valid kinds, normalised slips and informational kinds", () => {
|
|
1354
|
+
assert.deepEqual(amendmentKindProblems([{ kind: "defer", requirement: "R1", why: "w" }, { type: "limitation", requirement: "R2", why: "w" }, { kind: "scope-note", why: "w" }, { kind: "add-deliverable", path: "a.js", why: "w" }]), []);
|
|
1355
|
+
assert.deepEqual(amendmentKindProblems(undefined), []);
|
|
1356
|
+
});
|
|
1357
|
+
it("flags a missing path or requirement", () => {
|
|
1358
|
+
assert.equal(amendmentKindProblems([{ kind: "add-deliverable", why: "w" }, { kind: "defer", why: "w" }]).length, 2);
|
|
1359
|
+
});
|
|
1360
|
+
});
|
|
1361
|
+
|
|
1362
|
+
// Release review of #5103: MAT-1/REG-1 (non-code fixes are never "already fixed") and the read-only-prod routing theme
|
|
1363
|
+
// (SPEC-1, SEC-4, AUTH-1, BUG-2/3, QA-3, MAT-7, REG-4).
|
|
1364
|
+
import { READONLY_PROD_RE as RO5103, EXTERNAL_NEED_RE as EX5103, ownerRoute as route5103, alreadyFixedOnBase as afb5103 } from "./conformance.mjs";
|
|
1365
|
+
describe("release review #5103", () => {
|
|
1366
|
+
const green = [{ kind: "repro_base_green" }, { kind: "source_deliverable_untouched" }];
|
|
1367
|
+
it("a lane that changed css/json/yaml/sql/vue/tf is never closed as already fixed on base", () => {
|
|
1368
|
+
for (const f of ["web/app.css", "config/limits.json", "deploy/values.yaml", "db/0042.sql", "web/App.vue", "infra/main.tf", "web/index.html"]) {
|
|
1369
|
+
assert.equal(afb5103(green, [f, "test/x.test.js"]), false, f);
|
|
1370
|
+
}
|
|
1371
|
+
});
|
|
1372
|
+
it("tests and docs only still close as already fixed", () => {
|
|
1373
|
+
assert.equal(afb5103(green, ["test/x.test.js", "docs/a.md", "devdocs/x.mdx"]), true);
|
|
1374
|
+
});
|
|
1375
|
+
const host = (why) => RO5103.test(why) && !EX5103.test(why);
|
|
1376
|
+
it("read-only production telemetry, captures and queries route to host", () => {
|
|
1377
|
+
for (const why of ["read-only production telemetry from ovh", "ssh ovh-monitor to capture the queue", "obs-psql query of prod", "run a production query against the replica", "capture production logs for the run"]) assert.equal(host(why), true, why);
|
|
1378
|
+
});
|
|
1379
|
+
it("owner asks, writes, credentials and product wording stay external", () => {
|
|
1380
|
+
for (const why of ["Should we delete production data older than 90 days?", "add uptime-monitor dashboard", "needs the production database password to query prod data",
|
|
1381
|
+
"needs the prod ssh key to read production logs", "product manager decides the pricing", "production dataset retention policy", "drop the production table after the capture"]) {
|
|
1382
|
+
assert.equal(host(why), false, why);
|
|
1383
|
+
}
|
|
1384
|
+
assert.equal(route5103({ bucket: "owner:decide", why: "Should we delete production data older than 90 days?" }), "external");
|
|
1385
|
+
});
|
|
1386
|
+
});
|