From b3a108fb8b0ce870db4f80bb6096d5477105f4bf Mon Sep 17 00:00:00 2001 From: Sergio Velderrain Date: Mon, 30 Mar 2026 13:41:10 -0700 Subject: [PATCH 01/16] fix: restore queue health recovery progression (#2054) Co-authored-by: svelderrainruiz --- .../__tests__/queue-supervisor.test.mjs | 49 +++++++++++++++++++ tools/priority/queue-supervisor.mjs | 43 +++++++++++++--- 2 files changed, 85 insertions(+), 7 deletions(-) diff --git a/tools/priority/__tests__/queue-supervisor.test.mjs b/tools/priority/__tests__/queue-supervisor.test.mjs index 1c9c6a00b..7c1124b49 100644 --- a/tools/priority/__tests__/queue-supervisor.test.mjs +++ b/tools/priority/__tests__/queue-supervisor.test.mjs @@ -405,6 +405,25 @@ test('evaluateAdaptiveInflight applies hysteresis before upgrading from stabiliz assert.equal(secondRecovery.hysteresis.transition, 'upgrade-applied'); }); +test('evaluateAdaptiveInflight restores upgrade streak from persisted hysteresis state', () => { + const recovered = evaluateAdaptiveInflight({ + maxInflight: 5, + minInflight: 2, + adaptiveCap: true, + health: { successRate: 0.95, minSuccessRate: 0.8 }, + runtimeFleet: { totals: { queued: 0, inProgress: 0, stalled: 0 }, thresholds: { maxQueuedRuns: 6, maxInProgressRuns: 8 } }, + retryPressure: { retryRatio: 0, quarantineRatio: 0 }, + previousControllerState: { + mode: 'stabilize', + hysteresis: { + upgradeStreak: 1 + } + } + }); + assert.equal(recovered.tier, 'guarded'); + assert.equal(recovered.hysteresis.transition, 'upgrade-applied'); +}); + test('evaluateBurstWindow activates on release triggers and carries refill cycles', () => { const initial = evaluateBurstWindow({ burstMode: 'auto', @@ -616,6 +635,36 @@ test('evaluateHealthGate pauses when success rate drops or red window exceeds th assert.ok(redWindow.reasons.includes('trunk-red-window-exceeded')); }); +test('evaluateHealthGate ignores stale workflow failures outside the health lookback window', () => { + const now = new Date('2026-03-30T22:00:00.000Z'); + const result = evaluateHealthGate({ + workflowRunsByName: { + Validate: [ + { conclusion: 'success', status: 'completed', created_at: '2026-03-30T18:57:23Z', updated_at: '2026-03-30T19:13:12Z' }, + { conclusion: 'success', status: 'completed', created_at: '2026-03-29T22:01:19Z', updated_at: '2026-03-29T22:17:18Z' } + ], + 'Policy Guard (Upstream)': [ + { conclusion: 'success', status: 'completed', created_at: '2026-03-30T18:57:23Z', updated_at: '2026-03-30T18:58:03Z' } + ], + 'Fixture Drift Validation': [ + { conclusion: 'failure', status: 'completed', created_at: '2026-03-29T22:13:26Z', updated_at: '2026-03-29T22:15:13Z' }, + { conclusion: 'failure', status: 'completed', created_at: '2025-10-07T08:32:27Z', updated_at: '2025-10-07T08:35:06Z' }, + { conclusion: 'failure', status: 'completed', created_at: '2025-10-07T08:12:14Z', updated_at: '2025-10-07T08:14:27Z' }, + { conclusion: 'failure', status: 'completed', created_at: '2025-10-07T07:58:25Z', updated_at: '2025-10-07T08:00:46Z' } + ], + 'commit-integrity': [ + { conclusion: 'success', status: 'completed', created_at: '2026-03-29T22:24:27Z', updated_at: '2026-03-29T22:24:44Z' } + ] + }, + now + }); + + assert.equal(result.paused, false); + assert.equal(result.sampleSize, 5); + assert.equal(result.successful, 4); + assert.equal(result.successRate, 0.8); +}); + test('evaluateRuntimeFleetHealth pauses on saturation and stalled runs', () => { const now = new Date('2026-03-05T22:00:00.000Z'); const runtime = evaluateRuntimeFleetHealth({ diff --git a/tools/priority/queue-supervisor.mjs b/tools/priority/queue-supervisor.mjs index 492cd47d1..f4c530f98 100644 --- a/tools/priority/queue-supervisor.mjs +++ b/tools/priority/queue-supervisor.mjs @@ -36,6 +36,7 @@ const DEFAULT_MIN_INFLIGHT = 2; const DEFAULT_HEALTH_SAMPLE = 10; const DEFAULT_HEALTH_MIN_SUCCESS_RATE = 0.8; const DEFAULT_HEALTH_MAX_RED_MINUTES = 30; +const DEFAULT_HEALTH_LOOKBACK_DAYS = 30; const DEFAULT_MAX_QUEUED_RUNS = 6; const DEFAULT_MAX_IN_PROGRESS_RUNS = 8; const DEFAULT_STALL_THRESHOLD_MINUTES = 45; @@ -820,7 +821,8 @@ export function evaluateHealthGate({ workflowRunsByName, now = new Date(), minSuccessRate = DEFAULT_HEALTH_MIN_SUCCESS_RATE, - maxRedMinutes = DEFAULT_HEALTH_MAX_RED_MINUTES + maxRedMinutes = DEFAULT_HEALTH_MAX_RED_MINUTES, + lookbackDays = DEFAULT_HEALTH_LOOKBACK_DAYS }) { const runs = []; for (const [workflow, workflowRuns] of Object.entries(workflowRunsByName ?? {})) { @@ -836,12 +838,24 @@ export function evaluateHealthGate({ } } - runs.sort((a, b) => Date.parse(b.createdAt || 0) - Date.parse(a.createdAt || 0)); - const sampleSize = runs.length; - const successful = runs.filter((run) => run.conclusion === 'success').length; + runs.sort((a, b) => runTimestampMs(b) - runTimestampMs(a)); + const lookbackMs = + Number.isFinite(lookbackDays) && lookbackDays > 0 + ? lookbackDays * 24 * 60 * 60 * 1000 + : null; + const filteredRuns = + lookbackMs == null + ? runs + : runs.filter((run) => { + const stamp = runTimestampMs(run); + return Number.isFinite(stamp) && now.valueOf() - stamp <= lookbackMs; + }); + + const sampleSize = filteredRuns.length; + const successful = filteredRuns.filter((run) => run.conclusion === 'success').length; const successRate = sampleSize === 0 ? 0 : successful / sampleSize; - const latest = runs[0] ?? null; - const lastSuccess = runs.find((run) => run.conclusion === 'success') ?? null; + const latest = filteredRuns[0] ?? null; + const lastSuccess = filteredRuns.find((run) => run.conclusion === 'success') ?? null; let redMinutes = 0; if (latest && latest.conclusion !== 'success') { @@ -1208,6 +1222,20 @@ function applyControllerHysteresis({ }; } +function readPreviousControllerUpgradeStreak(previousControllerState) { + const direct = Number(previousControllerState?.upgradeStreak); + if (Number.isFinite(direct) && direct >= 0) { + return Math.trunc(direct); + } + + const nested = Number(previousControllerState?.hysteresis?.upgradeStreak); + if (Number.isFinite(nested) && nested >= 0) { + return Math.trunc(nested); + } + + return 0; +} + export function evaluateAdaptiveInflight({ maxInflight, minInflight = DEFAULT_MIN_INFLIGHT, @@ -1277,7 +1305,7 @@ export function evaluateAdaptiveInflight({ const hysteresis = applyControllerHysteresis({ desiredMode: desired.desiredMode, previousMode: previousControllerState?.mode, - previousUpgradeStreak: previousControllerState?.upgradeStreak, + previousUpgradeStreak: readPreviousControllerUpgradeStreak(previousControllerState), requiredUpgradeStreak: CONTROLLER_REQUIRED_UPGRADE_STREAK }); const effectiveMaxInflight = caps[hysteresis.mode] ?? configuredMin; @@ -1786,6 +1814,7 @@ export async function runQueueSupervisor(options = {}) { reasons: adaptiveInflight.reasons, metrics: adaptiveInflight.metrics, thresholds: adaptiveInflight.thresholds, + upgradeStreak: adaptiveInflight.hysteresis?.upgradeStreak ?? 0, hysteresis: adaptiveInflight.hysteresis, retryPressure, paused: uniquePausedReasons.length > 0, From b17db63ff5a24ff634356214d5f981f78a4ff24a Mon Sep 17 00:00:00 2001 From: Sergio Velderrain Date: Mon, 30 Mar 2026 13:58:27 -0700 Subject: [PATCH 02/16] policy: shrink develop required merge checks (#2055) Co-authored-by: svelderrainruiz --- docs/DEVELOPER_GUIDE.md | 6 +- docs/RELEASE_PROMOTION_CONTRACT.md | 12 + ...RUNBOOK_CONTAINER_LANE_PROMOTION_POLICY.md | 4 +- docs/knowledgebase/FEATURE_BRANCH_POLICY.md | 11 +- tools/policy/branch-required-checks.json | 10 - tools/policy/promotion-contract.json | 5 - .../__tests__/check-policy-apply.test.mjs | 268 ++++++++++++++---- tools/priority/policy.json | 10 - 8 files changed, 239 insertions(+), 87 deletions(-) diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index 23c200beb..df1423f90 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -463,7 +463,8 @@ For each cut: - Branch protection requires a linear history: use the **Squash and merge** button (or rebase-and-merge) so no merge commits land on `develop`/`main`. - Keep PRs focused and include the standing issue reference (`#`) in the commit subject and PR description. -- Ensure required checks (`validate`, `fixtures`, `session-index`) are green before merging; rerun as needed. +- Ensure required checks (`lint`, `fixtures`, `Policy Guard (Upstream) / policy-guard`, + `vi-history-scenarios-linux`, `commit-integrity`) are green before merging; rerun as needed. - `Validate` now computes a `validate-scope-plan` artifact before the heavy lanes fan out. Standard `pull_request` and `merge_group` runs classify changed paths with an allow-list into: `docs-metadata-only`, `tests-only`, `tools-policy-only`, `ci-control-plane`, `mixed-lightweight`, `compare-engine-history`, @@ -495,6 +496,9 @@ For each cut: the local CLI/orchestrator path, and `ready_for_review` is reserved for final hosted validation on the current head. - `agent-review-policy` is the hosted concentrator for local review evidence and promotion validation. It should not be used to acquire a second GitHub-side Copilot review after `ready_for_review`. +- `session-index`, `issue-snapshot`, `semver`, `agent-review-policy`, and `hook-parity` + remain valuable supporting lanes, but they are not part of the minimal merge-queue + required-check surface for `develop`. - Run `node tools/npm/run-script.mjs priority:policy` (or `node tools/npm/run-script.mjs priority:policy:sync`) if you need to audit merge settings locally; the command also runs during `priority:handoff-tests` and fails when repo/branch policy drifts. diff --git a/docs/RELEASE_PROMOTION_CONTRACT.md b/docs/RELEASE_PROMOTION_CONTRACT.md index 5cb590a64..c5b217cd6 100644 --- a/docs/RELEASE_PROMOTION_CONTRACT.md +++ b/docs/RELEASE_PROMOTION_CONTRACT.md @@ -61,6 +61,18 @@ Canonical required-check lists for `develop` and `release/*` must remain in sync `release/*`; `priority:policy` resolves it to the current live GitHub ruleset) +For `develop`, the required set is intentionally limited to the smallest merge-safety +surface that still preserves trunk truth: + +- `lint` +- `fixtures` +- `Policy Guard (Upstream) / policy-guard` +- `vi-history-scenarios-linux` +- `commit-integrity` + +Other Validate or platform lanes can still run and remain useful evidence, but they +must not be treated as queue-required unless they prove merge safety for `develop`. + The workflow context `Promotion Contract / promotion-contract` remains an operational evidence check, but it is not a branch-protection required status on `develop` or `release/*` because the workflow is intentionally path-scoped for pull requests. diff --git a/docs/RUNBOOK_CONTAINER_LANE_PROMOTION_POLICY.md b/docs/RUNBOOK_CONTAINER_LANE_PROMOTION_POLICY.md index 0269fa795..0310e60c6 100644 --- a/docs/RUNBOOK_CONTAINER_LANE_PROMOTION_POLICY.md +++ b/docs/RUNBOOK_CONTAINER_LANE_PROMOTION_POLICY.md @@ -73,8 +73,8 @@ Rollback behavior was validated against the canary state for #662/#663: - Canary lane remains non-required during this policy phase. - Current develop required-check contract remains satisfiable with no runbook-container context required. - Expected branch-protection behavior after rollback: merges continue using canonical required contexts (`lint`, - `fixtures`, `session-index`, `issue-snapshot`, `semver`, `Policy Guard (Upstream) / policy-guard`, - `vi-history-scenarios-linux`, `agent-review-policy`, `hook-parity`, `commit-integrity`). + `fixtures`, `Policy Guard (Upstream) / policy-guard`, `vi-history-scenarios-linux`, + `commit-integrity`). ## Decision for #663 diff --git a/docs/knowledgebase/FEATURE_BRANCH_POLICY.md b/docs/knowledgebase/FEATURE_BRANCH_POLICY.md index e70aeaf86..d37edde8c 100644 --- a/docs/knowledgebase/FEATURE_BRANCH_POLICY.md +++ b/docs/knowledgebase/FEATURE_BRANCH_POLICY.md @@ -1,7 +1,7 @@ # Feature Branch Enforcement & Merge Queue -| `develop` (live id may drift) | `refs/heads/develop` | Merge queue enabled (`merge_method=SQUASH`, `grouping=ALLGREEN`, build queue <=20 entries, 1-minute quiet window). Required checks: `lint`, `fixtures`, `session-index`, `issue-snapshot`, `semver`, `Policy Guard (Upstream) / policy-guard`, `vi-history-scenarios-linux`, `agent-review-policy`, `hook-parity`, `commit-integrity`. Non-required hosted proof lanes may run alongside the queue contract, including `vi-history-scenarios-windows` on GitHub-hosted `windows-2022`. Copilot review settings are no longer enforced through policy; draft/ready review semantics are repo-owned and validated by `agent-review-policy`. | +| `develop` (live id may drift) | `refs/heads/develop` | Merge queue enabled (`merge_method=SQUASH`, `grouping=ALLGREEN`, build queue <=20 entries, 1-minute quiet window). Required checks: `lint`, `fixtures`, `Policy Guard (Upstream) / policy-guard`, `vi-history-scenarios-linux`, `commit-integrity`. Non-required hosted proof lanes may run alongside the queue contract, including `session-index`, `issue-snapshot`, `semver`, `agent-review-policy`, `hook-parity`, and `vi-history-scenarios-windows` on GitHub-hosted `windows-2022`. Copilot review settings are no longer enforced through policy; draft/ready review semantics are repo-owned and validated by `agent-review-policy`. | ## Purpose @@ -84,7 +84,7 @@ promotion behavior, not the branch-class source of truth. ### GitHub rulesets | Manifest identity | Scope | Highlights | |-------------------|----------------------|----------------------------------------------------------------------------------------------| -| `develop` (live id may drift) | `refs/heads/develop` | Merge queue enabled (`merge_method=SQUASH`, `grouping=ALLGREEN`, build queue <=20 entries, 1-minute quiet window). Required checks: `lint`, `fixtures`, `session-index`, `issue-snapshot`, `semver`, `Policy Guard (Upstream) / policy-guard`, `vi-history-scenarios-linux`, `agent-review-policy`, `hook-parity`, `commit-integrity`. Non-required hosted proof lanes may run alongside the queue contract, including `vi-history-scenarios-windows` on GitHub-hosted `windows-2022`. Copilot review settings are no longer enforced through policy; draft/ready review semantics are repo-owned and validated by `agent-review-policy`. | +| `develop` (live id may drift) | `refs/heads/develop` | Merge queue enabled (`merge_method=SQUASH`, `grouping=ALLGREEN`, build queue <=20 entries, 1-minute quiet window). Required checks: `lint`, `fixtures`, `Policy Guard (Upstream) / policy-guard`, `vi-history-scenarios-linux`, `commit-integrity`. Non-required hosted proof lanes may run alongside the queue contract, including `session-index`, `issue-snapshot`, `semver`, `agent-review-policy`, `hook-parity`, and `vi-history-scenarios-windows` on GitHub-hosted `windows-2022`. Copilot review settings are no longer enforced through policy; draft/ready review semantics are repo-owned and validated by `agent-review-policy`. | | `main` (`tools/priority/policy.json` key `8614140`) | `refs/heads/main` | Merge queue enabled (`merge_method=SQUASH`, `grouping=ALLGREEN`, build queue <=5 entries, 1-minute quiet window). Required checks: `lint`, `pester`, `vi-binary-check`, `vi-compare`, `Policy Guard (Upstream) / policy-guard`, `commit-integrity`. Required approving reviews: `0`. | | `release` (`tools/priority/policy.json` key `8614172`) | `refs/heads/release/*` | No merge queue; protects against force-push/deletion. Required checks: `lint`, `pester / normalize`, `smoke-gate`, `Policy Guard (Upstream) / policy-guard`, `commit-integrity`. Required approving reviews: `0`. | @@ -126,9 +126,10 @@ checked into `tools/priority/policy.json` so `priority:policy` stays authoritati ### `develop` - **Merge strategy**: queue-managed squash with linear history enforced; merge commits disabled. -- **Required checks**: `lint`, `fixtures`, `session-index`, `issue-snapshot`, `semver`, - `Policy Guard (Upstream) / policy-guard`, - `vi-history-scenarios-linux`, `agent-review-policy`, `hook-parity`, `commit-integrity`. +- **Required checks**: `lint`, `fixtures`, `Policy Guard (Upstream) / policy-guard`, + `vi-history-scenarios-linux`, `commit-integrity`. +- **Supporting non-required lanes**: `session-index`, `issue-snapshot`, `semver`, + `agent-review-policy`, `hook-parity`, plus hosted Windows proving. - **Non-required hosted proof**: `vi-history-scenarios-windows` may run on GitHub-hosted `windows-2022` to validate `nationalinstruments/labview:2026q1-windows`. Agents may dispatch that hosted lane while manually running the Linux or Windows Docker Desktop/WSL2 lanes on this host. diff --git a/tools/policy/branch-required-checks.json b/tools/policy/branch-required-checks.json index 0e792f0d4..c37a2cdc9 100644 --- a/tools/policy/branch-required-checks.json +++ b/tools/policy/branch-required-checks.json @@ -11,13 +11,8 @@ "upstream-integration": [ "lint", "fixtures", - "session-index", - "issue-snapshot", - "semver", "Policy Guard (Upstream) / policy-guard", "vi-history-scenarios-linux", - "agent-review-policy", - "hook-parity", "commit-integrity" ], "downstream-consumer-proving-rail": [ @@ -43,13 +38,8 @@ "develop": [ "lint", "fixtures", - "session-index", - "issue-snapshot", - "semver", "Policy Guard (Upstream) / policy-guard", "vi-history-scenarios-linux", - "agent-review-policy", - "hook-parity", "commit-integrity" ], "downstream/develop": [ diff --git a/tools/policy/promotion-contract.json b/tools/policy/promotion-contract.json index 9afb6e275..7ee2e25c8 100644 --- a/tools/policy/promotion-contract.json +++ b/tools/policy/promotion-contract.json @@ -48,13 +48,8 @@ "develop": [ "lint", "fixtures", - "session-index", - "issue-snapshot", - "semver", "Policy Guard (Upstream) / policy-guard", "vi-history-scenarios-linux", - "agent-review-policy", - "hook-parity", "commit-integrity" ], "release/*": [ diff --git a/tools/priority/__tests__/check-policy-apply.test.mjs b/tools/priority/__tests__/check-policy-apply.test.mjs index bf47cad37..4dade15c0 100644 --- a/tools/priority/__tests__/check-policy-apply.test.mjs +++ b/tools/priority/__tests__/check-policy-apply.test.mjs @@ -25,16 +25,15 @@ function createResponse(data, status = 200, statusText = 'OK') { const EXPECTED_DEVELOP_CHECKS = [ 'lint', 'fixtures', - 'session-index', - 'issue-snapshot', - 'semver', 'Policy Guard (Upstream) / policy-guard', 'vi-history-scenarios-linux', - 'agent-review-policy', - 'hook-parity', 'commit-integrity' ]; +const EXPECTED_DOWNSTREAM_DEVELOP_CHECKS = [ + 'Downstream Promotion / downstream-promotion' +]; + const EXPECTED_MAIN_CHECKS = [ 'lint', 'pester', @@ -186,7 +185,7 @@ function createRuleset({ }; } -function createAlignedRulesets(ids = { develop: 8811898, main: 8614140, release: 8614172 }) { +function createAlignedRulesets(ids = { develop: 8811898, main: 8614140, release: 8614172, downstream: 8811901 }) { return { develop: createRuleset({ id: ids.develop, @@ -215,6 +214,16 @@ function createAlignedRulesets(ids = { develop: 8811898, main: 8614140, release: dismissStaleReviewsOnPush: true, allowedMergeMethods: ['rebase'] }) + }), + downstreamDevelop: createRuleset({ + id: ids.downstream, + name: 'downstream-develop', + includes: ['refs/heads/downstream/develop'], + requiredStatusChecks: EXPECTED_DOWNSTREAM_DEVELOP_CHECKS, + requiredLinearHistory: true, + pullRequestRule: createPullRequestRule({ + allowedMergeMethods: ['rebase'] + }) }) }; } @@ -263,6 +272,7 @@ test('priority:policy --apply updates rulesets for develop/main/release', async const rulesetDevelopUrl = `${repoUrl}/rulesets/8811898`; const rulesetMainUrl = `${repoUrl}/rulesets/8614140`; const rulesetReleaseUrl = `${repoUrl}/rulesets/8614172`; + const rulesetDownstreamDevelopUrl = `${repoUrl}/rulesets/8811901`; const repoState = { allow_squash_merge: true, @@ -409,7 +419,19 @@ test('priority:policy --apply updates rulesets for develop/main/release', async ] }; + const rulesetDownstreamDevelop = createRuleset({ + id: 8811901, + name: 'downstream-develop', + includes: ['refs/heads/downstream/develop'], + requiredStatusChecks: EXPECTED_DOWNSTREAM_DEVELOP_CHECKS, + requiredLinearHistory: true, + pullRequestRule: createPullRequestRule({ + allowedMergeMethods: ['rebase'] + }) + }); + const branchDevelopUrl = `${repoUrl}/branches/develop/protection`; + const branchDownstreamDevelopUrl = `${repoUrl}/branches/downstream%2Fdevelop/protection`; const branchMainUrl = `${repoUrl}/branches/main/protection`; let branchDevelopProtection = { @@ -458,6 +480,8 @@ test('priority:policy --apply updates rulesets for develop/main/release', async allow_fork_syncing: { enabled: false } }; + const branchDownstreamDevelopProtection = createAlignedBranchProtection(EXPECTED_DOWNSTREAM_DEVELOP_CHECKS); + const wrapEnabled = (value) => ({ enabled: Boolean(value) }); const requests = []; const fetchMock = async (url, options = {}) => { @@ -524,11 +548,16 @@ test('priority:policy --apply updates rulesets for develop/main/release', async } } + if (method === 'GET' && url === branchDownstreamDevelopUrl) { + return createResponse(branchDownstreamDevelopProtection); + } + if (method === 'GET' && url === listUrl) { return createResponse([ toRulesetSummary(rulesetDevelop), toRulesetSummary(rulesetMain), - toRulesetSummary(rulesetRelease) + toRulesetSummary(rulesetRelease), + toRulesetSummary(rulesetDownstreamDevelop) ]); } @@ -567,6 +596,10 @@ test('priority:policy --apply updates rulesets for develop/main/release', async } } + if (method === 'GET' && url === rulesetDownstreamDevelopUrl) { + return createResponse(rulesetDownstreamDevelop); + } + throw new Error(`Unexpected request ${method} ${url}`); }; @@ -707,6 +740,7 @@ test('priority:policy verifies fork-local rulesets by stable identity when manif const repoUrl = 'https://api.github.com/repos/test-org/test-repo'; const listUrl = `${repoUrl}/rulesets`; const branchDevelopUrl = `${repoUrl}/branches/develop/protection`; + const branchDownstreamDevelopUrl = `${repoUrl}/branches/downstream%2Fdevelop/protection`; const branchMainUrl = `${repoUrl}/branches/main/protection`; const expectedRulesetUrls = { main: `${repoUrl}/rulesets/8614140`, @@ -715,7 +749,8 @@ test('priority:policy verifies fork-local rulesets by stable identity when manif const rulesets = createAlignedRulesets({ develop: 99001, main: 99002, - release: 99003 + release: 99003, + downstream: 99004 }); const fetchMock = async (url, options = {}) => { @@ -729,6 +764,9 @@ test('priority:policy verifies fork-local rulesets by stable identity when manif if (url === branchDevelopUrl) { return createResponse(createAlignedBranchProtection(EXPECTED_DEVELOP_CHECKS)); } + if (url === branchDownstreamDevelopUrl) { + return createResponse(createAlignedBranchProtection(EXPECTED_DOWNSTREAM_DEVELOP_CHECKS)); + } if (url === branchMainUrl) { return createResponse(createAlignedBranchProtection(EXPECTED_MAIN_CHECKS)); } @@ -739,7 +777,8 @@ test('priority:policy verifies fork-local rulesets by stable identity when manif return createResponse([ toRulesetSummary(rulesets.develop), toRulesetSummary(rulesets.main), - toRulesetSummary(rulesets.release) + toRulesetSummary(rulesets.release), + toRulesetSummary(rulesets.downstreamDevelop) ]); } if (url === `${repoUrl}/rulesets/${rulesets.develop.id}`) { @@ -751,6 +790,9 @@ test('priority:policy verifies fork-local rulesets by stable identity when manif if (url === `${repoUrl}/rulesets/${rulesets.release.id}`) { return createResponse(rulesets.release); } + if (url === `${repoUrl}/rulesets/${rulesets.downstreamDevelop.id}`) { + return createResponse(rulesets.downstreamDevelop); + } throw new Error(`Unexpected request ${method} ${url}`); }; @@ -787,6 +829,7 @@ test('priority:policy --apply updates fork-local rulesets resolved by stable ide const repoUrl = 'https://api.github.com/repos/test-org/test-repo'; const listUrl = `${repoUrl}/rulesets`; const branchDevelopUrl = `${repoUrl}/branches/develop/protection`; + const branchDownstreamDevelopUrl = `${repoUrl}/branches/downstream%2Fdevelop/protection`; const branchMainUrl = `${repoUrl}/branches/main/protection`; const expectedRulesetUrls = { main: `${repoUrl}/rulesets/8614140`, @@ -795,7 +838,8 @@ test('priority:policy --apply updates fork-local rulesets resolved by stable ide const rulesets = createAlignedRulesets({ develop: 99101, main: 99102, - release: 99103 + release: 99103, + downstream: 99104 }); rulesets.develop.conditions.ref_name.include = ['refs/heads/develop-drifted']; rulesets.develop.rules = rulesets.develop.rules.filter( @@ -838,6 +882,9 @@ test('priority:policy --apply updates fork-local rulesets resolved by stable ide if (method === 'GET' && url === branchDevelopUrl) { return createResponse(createAlignedBranchProtection(EXPECTED_DEVELOP_CHECKS, FORK_MIRROR_BRANCH_PROTECTION)); } + if (method === 'GET' && url === branchDownstreamDevelopUrl) { + return createResponse(createAlignedBranchProtection(EXPECTED_DOWNSTREAM_DEVELOP_CHECKS)); + } if (method === 'GET' && url === branchMainUrl) { return createResponse(createAlignedBranchProtection(EXPECTED_MAIN_CHECKS)); } @@ -848,7 +895,8 @@ test('priority:policy --apply updates fork-local rulesets resolved by stable ide return createResponse([ toRulesetSummary(rulesets.develop), toRulesetSummary(rulesets.main), - toRulesetSummary(rulesets.release) + toRulesetSummary(rulesets.release), + toRulesetSummary(rulesets.downstreamDevelop) ]); } if (url === `${repoUrl}/rulesets/${rulesets.develop.id}`) { @@ -878,6 +926,15 @@ test('priority:policy --apply updates fork-local rulesets resolved by stable ide return createResponse(rulesets.release); } } + if (url === `${repoUrl}/rulesets/${rulesets.downstreamDevelop.id}`) { + if (method === 'GET') { + return createResponse(rulesets.downstreamDevelop); + } + if (method === 'PUT') { + rulesets.downstreamDevelop = updateRuleset(rulesets.downstreamDevelop, JSON.parse(options.body)); + return createResponse(rulesets.downstreamDevelop); + } + } throw new Error(`Unexpected request ${method} ${url}`); }; @@ -1021,6 +1078,7 @@ test('priority:policy --apply creates missing fork-local rulesets when no identi const repoUrl = 'https://api.github.com/repos/test-org/test-repo'; const listUrl = `${repoUrl}/rulesets`; const branchDevelopUrl = `${repoUrl}/branches/develop/protection`; + const branchDownstreamDevelopUrl = `${repoUrl}/branches/downstream%2Fdevelop/protection`; const branchMainUrl = `${repoUrl}/branches/main/protection`; const createdRulesets = []; let nextRulesetId = 99200; @@ -1038,6 +1096,9 @@ test('priority:policy --apply creates missing fork-local rulesets when no identi if (method === 'GET' && url === branchDevelopUrl) { return createResponse(createAlignedBranchProtection(EXPECTED_DEVELOP_CHECKS, FORK_MIRROR_BRANCH_PROTECTION)); } + if (method === 'GET' && url === branchDownstreamDevelopUrl) { + return createResponse(createAlignedBranchProtection(EXPECTED_DOWNSTREAM_DEVELOP_CHECKS)); + } if (method === 'GET' && url === branchMainUrl) { return createResponse(createAlignedBranchProtection(EXPECTED_MAIN_CHECKS)); } @@ -1089,8 +1150,8 @@ test('priority:policy --apply creates missing fork-local rulesets when no identi assert.equal(createdRulesets.length, 3, 'three fork-local rulesets should be created'); assert.deepEqual( createdRulesets.map((ruleset) => ruleset.name).sort(), - ['develop', 'main', 'release'], - 'created rulesets should cover develop/main/release identities' + ['develop', 'downstream-develop', 'main'], + 'created rulesets should cover develop/main/downstream identities' ); const createdDevelopRuleset = createdRulesets.find((ruleset) => ruleset.name === 'develop'); assert.ok( @@ -1103,6 +1164,7 @@ test('priority:policy verify passes on user-owned throughput forks while still e const repoUrl = 'https://api.github.com/repos/test-user/test-repo'; const listUrl = `${repoUrl}/rulesets`; const branchDevelopUrl = `${repoUrl}/branches/develop/protection`; + const branchDownstreamDevelopUrl = `${repoUrl}/branches/downstream%2Fdevelop/protection`; const branchMainUrl = `${repoUrl}/branches/main/protection`; const rulesetReleaseUrl = `${repoUrl}/rulesets/8614172`; const reportDir = await mkdtemp(path.join(os.tmpdir(), 'priority-policy-throughput-verify-')); @@ -1130,6 +1192,9 @@ test('priority:policy verify passes on user-owned throughput forks while still e if (method === 'GET' && url === branchDevelopUrl) { return createResponse(createAlignedBranchProtection(EXPECTED_DEVELOP_CHECKS, FORK_MIRROR_BRANCH_PROTECTION)); } + if (method === 'GET' && url === branchDownstreamDevelopUrl) { + return createResponse(createAlignedBranchProtection(EXPECTED_DOWNSTREAM_DEVELOP_CHECKS)); + } if (method === 'GET' && url === branchMainUrl) { return createResponse(createAlignedBranchProtection(EXPECTED_MAIN_CHECKS)); } @@ -1140,10 +1205,13 @@ test('priority:policy verify passes on user-owned throughput forks while still e return createResponse({ message: 'Not Found', status: '404' }, 404, 'Not Found'); } if (method === 'GET' && url === rulesetReleaseUrl) { - return createResponse(alignedRulesets.release); + return createResponse({ message: 'Not Found', status: '404' }, 404, 'Not Found'); } if (method === 'GET' && url === listUrl) { - return createResponse([]); + return createResponse([toRulesetSummary(alignedRulesets.downstreamDevelop)]); + } + if (method === 'GET' && url === `${repoUrl}/rulesets/${alignedRulesets.downstreamDevelop.id}`) { + return createResponse(alignedRulesets.downstreamDevelop); } throw new Error(`Unexpected request ${method} ${url}`); }; @@ -1185,6 +1253,7 @@ test('priority:policy verify honors repo portability overrides for forks that ca const repoUrl = 'https://api.github.com/repos/test-org/test-fork'; const listUrl = `${repoUrl}/rulesets`; const branchDevelopUrl = `${repoUrl}/branches/develop/protection`; + const branchDownstreamDevelopUrl = `${repoUrl}/branches/downstream%2Fdevelop/protection`; const branchMainUrl = `${repoUrl}/branches/main/protection`; const rulesetReleaseUrl = `${repoUrl}/rulesets/8614172`; const reportDir = await mkdtemp(path.join(os.tmpdir(), 'priority-policy-throughput-override-')); @@ -1212,6 +1281,9 @@ test('priority:policy verify honors repo portability overrides for forks that ca if (method === 'GET' && url === branchDevelopUrl) { return createResponse(createAlignedBranchProtection(EXPECTED_DEVELOP_CHECKS, FORK_MIRROR_BRANCH_PROTECTION)); } + if (method === 'GET' && url === branchDownstreamDevelopUrl) { + return createResponse(createAlignedBranchProtection(EXPECTED_DOWNSTREAM_DEVELOP_CHECKS)); + } if (method === 'GET' && url === branchMainUrl) { return createResponse(createAlignedBranchProtection(EXPECTED_MAIN_CHECKS)); } @@ -1222,10 +1294,13 @@ test('priority:policy verify honors repo portability overrides for forks that ca return createResponse({ message: 'Not Found', status: '404' }, 404, 'Not Found'); } if (method === 'GET' && url === rulesetReleaseUrl) { - return createResponse(alignedRulesets.release); + return createResponse({ message: 'Not Found', status: '404' }, 404, 'Not Found'); } if (method === 'GET' && url === listUrl) { - return createResponse([]); + return createResponse([toRulesetSummary(alignedRulesets.downstreamDevelop)]); + } + if (method === 'GET' && url === `${repoUrl}/rulesets/${alignedRulesets.downstreamDevelop.id}`) { + return createResponse(alignedRulesets.downstreamDevelop); } throw new Error(`Unexpected request ${method} ${url}`); }; @@ -1401,10 +1476,11 @@ test('priority:policy --apply skips queue-managed rulesets on user-owned through const repoUrl = 'https://api.github.com/repos/test-user/test-repo'; const listUrl = `${repoUrl}/rulesets`; const branchDevelopUrl = `${repoUrl}/branches/develop/protection`; + const branchDownstreamDevelopUrl = `${repoUrl}/branches/downstream%2Fdevelop/protection`; const branchMainUrl = `${repoUrl}/branches/main/protection`; const rulesetReleaseUrl = `${repoUrl}/rulesets/8614172`; const requests = []; - let createdReleaseRuleset = null; + let createdDownstreamRuleset = null; const manifestOverride = JSON.parse(await readFile(new URL('../policy.json', import.meta.url), 'utf8')); manifestOverride.repoProfiles = { ...(manifestOverride.repoProfiles ?? {}), @@ -1428,6 +1504,9 @@ test('priority:policy --apply skips queue-managed rulesets on user-owned through if (method === 'GET' && url === branchDevelopUrl) { return createResponse(createAlignedBranchProtection(EXPECTED_DEVELOP_CHECKS, FORK_MIRROR_BRANCH_PROTECTION)); } + if (method === 'GET' && url === branchDownstreamDevelopUrl) { + return createResponse(createAlignedBranchProtection(EXPECTED_DOWNSTREAM_DEVELOP_CHECKS)); + } if (method === 'GET' && url === branchMainUrl) { return createResponse(createAlignedBranchProtection(EXPECTED_MAIN_CHECKS)); } @@ -1438,24 +1517,27 @@ test('priority:policy --apply skips queue-managed rulesets on user-owned through return createResponse({ message: 'Not Found', status: '404' }, 404, 'Not Found'); } if (method === 'GET' && url === rulesetReleaseUrl) { - if (createdReleaseRuleset) { - return createResponse(createdReleaseRuleset); - } return createResponse({ message: 'Not Found', status: '404' }, 404, 'Not Found'); } if (method === 'GET' && url === listUrl) { - return createResponse([]); + return createResponse(createdDownstreamRuleset ? [toRulesetSummary(createdDownstreamRuleset)] : []); + } + if (method === 'GET' && url === `${repoUrl}/rulesets/99201`) { + if (createdDownstreamRuleset) { + return createResponse(createdDownstreamRuleset); + } + return createResponse({ message: 'Not Found', status: '404' }, 404, 'Not Found'); } if (method === 'POST' && url === listUrl) { const payload = JSON.parse(options.body); - createdReleaseRuleset = { + createdDownstreamRuleset = { ...payload, - id: 8614172, + id: 99201, enforcement: payload.enforcement ?? 'active', target: payload.target ?? 'branch', bypass_actors: payload.bypass_actors ?? [] }; - return createResponse(createdReleaseRuleset, 201, 'Created'); + return createResponse(createdDownstreamRuleset, 201, 'Created'); } throw new Error(`Unexpected request ${method} ${url}`); }; @@ -1478,8 +1560,9 @@ test('priority:policy --apply skips queue-managed rulesets on user-owned through assert.equal(code, 0, 'apply mode should succeed on user-owned throughput forks'); const createRequests = requests.filter((entry) => entry.method === 'POST' && entry.url === listUrl); - assert.equal(createRequests.length, 1, 'apply mode should only create the non-queue release ruleset'); + assert.equal(createRequests.length, 1, 'apply mode should only create the non-queue downstream-develop ruleset'); const createdPayload = JSON.parse(createRequests[0].body); + assert.equal(createdPayload.name, 'downstream-develop'); assert.equal( createdPayload.rules.some((rule) => rule?.type === 'merge_queue'), false, @@ -1491,12 +1574,13 @@ test('priority:policy --apply downgrades queue-managed rulesets when a fork reje const repoUrl = 'https://api.github.com/repos/test-org/test-fork'; const listUrl = `${repoUrl}/rulesets`; const branchDevelopUrl = `${repoUrl}/branches/develop/protection`; + const branchDownstreamDevelopUrl = `${repoUrl}/branches/downstream%2Fdevelop/protection`; const branchMainUrl = `${repoUrl}/branches/main/protection`; const rulesetReleaseUrl = `${repoUrl}/rulesets/8614172`; const reportDir = await mkdtemp(path.join(os.tmpdir(), 'priority-policy-throughput-apply-')); const reportPath = path.join(reportDir, 'policy-report.json'); const requests = []; - let createdReleaseRuleset = null; + const createdRulesets = []; const fetchMock = async (url, options = {}) => { const method = options.method ?? 'GET'; @@ -1512,6 +1596,9 @@ test('priority:policy --apply downgrades queue-managed rulesets when a fork reje if (method === 'GET' && url === branchDevelopUrl) { return createResponse(createAlignedBranchProtection(EXPECTED_DEVELOP_CHECKS, FORK_MIRROR_BRANCH_PROTECTION)); } + if (method === 'GET' && url === branchDownstreamDevelopUrl) { + return createResponse(createAlignedBranchProtection(EXPECTED_DOWNSTREAM_DEVELOP_CHECKS)); + } if (method === 'GET' && url === branchMainUrl) { return createResponse(createAlignedBranchProtection(EXPECTED_MAIN_CHECKS)); } @@ -1522,13 +1609,18 @@ test('priority:policy --apply downgrades queue-managed rulesets when a fork reje return createResponse({ message: 'Not Found', status: '404' }, 404, 'Not Found'); } if (method === 'GET' && url === rulesetReleaseUrl) { - if (createdReleaseRuleset) { - return createResponse(createdReleaseRuleset); - } return createResponse({ message: 'Not Found', status: '404' }, 404, 'Not Found'); } if (method === 'GET' && url === listUrl) { - return createResponse([]); + return createResponse(createdRulesets.map((ruleset) => toRulesetSummary(ruleset))); + } + if (method === 'GET' && url.startsWith(`${repoUrl}/rulesets/`)) { + const id = Number(url.split('/').at(-1)); + const match = createdRulesets.find((ruleset) => ruleset.id === id); + if (match) { + return createResponse(match); + } + return createResponse({ message: 'Not Found', status: '404' }, 404, 'Not Found'); } if (method === 'POST' && url === listUrl) { const payload = JSON.parse(options.body); @@ -1542,14 +1634,15 @@ test('priority:policy --apply downgrades queue-managed rulesets when a fork reje 'Unprocessable Entity' ); } - createdReleaseRuleset = { + const created = { ...payload, - id: 8614172, + id: 99210 + createdRulesets.length, enforcement: payload.enforcement ?? 'active', target: payload.target ?? 'branch', bypass_actors: payload.bypass_actors ?? [] }; - return createResponse(createdReleaseRuleset, 201, 'Created'); + createdRulesets.push(created); + return createResponse(created, 201, 'Created'); } throw new Error(`Unexpected request ${method} ${url}`); }; @@ -1595,6 +1688,7 @@ test('priority:policy --apply keeps fork develop on the mirror-rail override for const repoUrl = 'https://api.github.com/repos/LabVIEW-Community-CI-CD/compare-vi-cli-action-fork'; const listUrl = `${repoUrl}/rulesets`; const branchDevelopUrl = `${repoUrl}/branches/develop/protection`; + const branchDownstreamDevelopUrl = `${repoUrl}/branches/downstream%2Fdevelop/protection`; const branchMainUrl = `${repoUrl}/branches/main/protection`; const alignedRulesets = createAlignedRulesets(); let developProtection = createAlignedBranchProtection(EXPECTED_DEVELOP_CHECKS); @@ -1613,6 +1707,9 @@ test('priority:policy --apply keeps fork develop on the mirror-rail override for if (method === 'GET' && url === branchDevelopUrl) { return createResponse(developProtection); } + if (method === 'GET' && url === branchDownstreamDevelopUrl) { + return createResponse(createAlignedBranchProtection(EXPECTED_DOWNSTREAM_DEVELOP_CHECKS)); + } if (method === 'GET' && url === branchMainUrl) { return createResponse(createAlignedBranchProtection(EXPECTED_MAIN_CHECKS)); } @@ -1636,10 +1733,17 @@ test('priority:policy --apply keeps fork develop on the mirror-rail override for return createResponse(alignedRulesets.main); } if (method === 'GET' && url === `${repoUrl}/rulesets/8614172`) { - return createResponse(alignedRulesets.release); + return createResponse({ message: 'Not Found', status: '404' }, 404, 'Not Found'); + } + if (method === 'GET' && url === `${repoUrl}/rulesets/${alignedRulesets.downstreamDevelop.id}`) { + return createResponse(alignedRulesets.downstreamDevelop); } if (method === 'GET' && url === listUrl) { - return createResponse(Object.values(alignedRulesets).map(toRulesetSummary)); + return createResponse([ + toRulesetSummary(alignedRulesets.develop), + toRulesetSummary(alignedRulesets.main), + toRulesetSummary(alignedRulesets.downstreamDevelop) + ]); } throw new Error(`Unexpected request ${method} ${url}`); }; @@ -1675,11 +1779,13 @@ test('priority:policy --apply enforces required checks after merge_queue portabi const repoUrl = 'https://api.github.com/repos/test-org/test-fork'; const listUrl = `${repoUrl}/rulesets`; const branchDevelopUrl = `${repoUrl}/branches/develop/protection`; + const branchDownstreamDevelopUrl = `${repoUrl}/branches/downstream%2Fdevelop/protection`; const branchMainUrl = `${repoUrl}/branches/main/protection`; const rulesetReleaseUrl = `${repoUrl}/rulesets/8614172`; const requests = []; - let createdReleaseRuleset = null; + const createdRulesets = []; let developProtection = createAlignedBranchProtection([], FORK_MIRROR_BRANCH_PROTECTION); + let downstreamDevelopProtection = createAlignedBranchProtection(EXPECTED_DOWNSTREAM_DEVELOP_CHECKS); let mainProtection = createAlignedBranchProtection([]); const fetchMock = async (url, options = {}) => { @@ -1696,6 +1802,9 @@ test('priority:policy --apply enforces required checks after merge_queue portabi if (method === 'GET' && url === branchDevelopUrl) { return createResponse(developProtection); } + if (method === 'GET' && url === branchDownstreamDevelopUrl) { + return createResponse(downstreamDevelopProtection); + } if (method === 'GET' && url === branchMainUrl) { return createResponse(mainProtection); } @@ -1728,13 +1837,18 @@ test('priority:policy --apply enforces required checks after merge_queue portabi return createResponse({ message: 'Not Found', status: '404' }, 404, 'Not Found'); } if (method === 'GET' && url === rulesetReleaseUrl) { - if (createdReleaseRuleset) { - return createResponse(createdReleaseRuleset); - } return createResponse({ message: 'Not Found', status: '404' }, 404, 'Not Found'); } if (method === 'GET' && url === listUrl) { - return createResponse([]); + return createResponse(createdRulesets.map((ruleset) => toRulesetSummary(ruleset))); + } + if (method === 'GET' && url.startsWith(`${repoUrl}/rulesets/`)) { + const id = Number(url.split('/').at(-1)); + const match = createdRulesets.find((ruleset) => ruleset.id === id); + if (match) { + return createResponse(match); + } + return createResponse({ message: 'Not Found', status: '404' }, 404, 'Not Found'); } if (method === 'POST' && url === listUrl) { const payload = JSON.parse(options.body); @@ -1748,14 +1862,15 @@ test('priority:policy --apply enforces required checks after merge_queue portabi 'Unprocessable Entity' ); } - createdReleaseRuleset = { + const created = { ...payload, - id: 8614172, + id: 99310 + createdRulesets.length, enforcement: payload.enforcement ?? 'active', target: payload.target ?? 'branch', bypass_actors: payload.bypass_actors ?? [] }; - return createResponse(createdReleaseRuleset, 201, 'Created'); + createdRulesets.push(created); + return createResponse(created, 201, 'Created'); } throw new Error(`Unexpected request ${method} ${url}`); }; @@ -2410,6 +2525,7 @@ test('priority:policy verify uses queue-managed rulesets as required-check sourc const rulesetMainUrl = `${repoUrl}/rulesets/8614140`; const rulesetReleaseUrl = `${repoUrl}/rulesets/8614172`; const branchDevelopUrl = `${repoUrl}/branches/develop/protection`; + const branchDownstreamDevelopUrl = `${repoUrl}/branches/downstream%2Fdevelop/protection`; const branchMainUrl = `${repoUrl}/branches/main/protection`; const repoState = { @@ -2432,15 +2548,7 @@ test('priority:policy verify uses queue-managed rulesets as required-check sourc 'Policy Guard (Upstream) / policy-guard', 'commit-integrity' ]; - const releaseChecksExpected = [ - 'lint', - 'pester', - 'publish', - 'vi-binary-check', - 'vi-compare', - 'mock-cli', - 'Policy Guard (Upstream) / policy-guard' - ]; + const releaseChecksExpected = [...EXPECTED_RELEASE_CHECKS]; const branchDevelopProtection = { required_status_checks: { @@ -2480,6 +2588,8 @@ test('priority:policy verify uses queue-managed rulesets as required-check sourc allow_fork_syncing: { enabled: false } }; + const branchDownstreamDevelopProtection = createAlignedBranchProtection(EXPECTED_DOWNSTREAM_DEVELOP_CHECKS); + const developMergeQueueParams = { merge_method: 'SQUASH', grouping_strategy: 'ALLGREEN', @@ -2586,6 +2696,33 @@ test('priority:policy verify uses queue-managed rulesets as required-check sourc ] }; + const rulesetDownstreamDevelop = { + id: 99004, + name: 'downstream-develop', + target: 'branch', + conditions: { ref_name: { include: ['refs/heads/downstream/develop'], exclude: [] } }, + rules: [ + { type: 'required_linear_history' }, + { + type: 'required_status_checks', + parameters: { + required_status_checks: EXPECTED_DOWNSTREAM_DEVELOP_CHECKS.map((context) => ({ context })) + } + }, + { + type: 'pull_request', + parameters: { + required_approving_review_count: 0, + dismiss_stale_reviews_on_push: false, + require_code_owner_review: false, + require_last_push_approval: false, + required_review_thread_resolution: false, + allowed_merge_methods: ['rebase'] + } + } + ] + }; + const fetchMock = async (url, options = {}) => { const method = options.method ?? 'GET'; if (method !== 'GET') { @@ -2597,6 +2734,9 @@ test('priority:policy verify uses queue-managed rulesets as required-check sourc if (url === branchDevelopUrl) { return createResponse(branchDevelopProtection); } + if (url === branchDownstreamDevelopUrl) { + return createResponse(branchDownstreamDevelopProtection); + } if (url === branchMainUrl) { return createResponse(branchMainProtection); } @@ -2604,12 +2744,16 @@ test('priority:policy verify uses queue-managed rulesets as required-check sourc return createResponse([ toRulesetSummary(rulesetDevelop), toRulesetSummary(rulesetMain), - toRulesetSummary(rulesetRelease) + toRulesetSummary(rulesetRelease), + toRulesetSummary(rulesetDownstreamDevelop) ]); } if (url === `${repoUrl}/rulesets/${rulesetDevelop.id}`) { return createResponse(rulesetDevelop); } + if (url === `${repoUrl}/rulesets/${rulesetDownstreamDevelop.id}`) { + return createResponse(rulesetDownstreamDevelop); + } if (url === rulesetMainUrl) { return createResponse(rulesetMain); } @@ -2652,6 +2796,7 @@ test('priority:policy --apply preserves branch required checks when queue-manage const repoUrl = 'https://api.github.com/repos/test-org/test-repo'; const listUrl = `${repoUrl}/rulesets`; const branchDevelopUrl = `${repoUrl}/branches/develop/protection`; + const branchDownstreamDevelopUrl = `${repoUrl}/branches/downstream%2Fdevelop/protection`; const branchMainUrl = `${repoUrl}/branches/main/protection`; const rulesets = createAlignedRulesets(); let developProtection = { @@ -2672,6 +2817,9 @@ test('priority:policy --apply preserves branch required checks when queue-manage if (method === 'GET' && url === branchDevelopUrl) { return createResponse(developProtection); } + if (method === 'GET' && url === branchDownstreamDevelopUrl) { + return createResponse(createAlignedBranchProtection(EXPECTED_DOWNSTREAM_DEVELOP_CHECKS)); + } if (method === 'GET' && url === branchMainUrl) { return createResponse(mainProtection); } @@ -2696,6 +2844,9 @@ test('priority:policy --apply preserves branch required checks when queue-manage if (method === 'GET' && url === `${repoUrl}/rulesets/8614172`) { return createResponse(rulesets.release); } + if (method === 'GET' && url === `${repoUrl}/rulesets/${rulesets.downstreamDevelop.id}`) { + return createResponse(rulesets.downstreamDevelop); + } if (method === 'GET' && url === listUrl) { return createResponse(Object.values(rulesets).map(toRulesetSummary)); } @@ -3114,14 +3265,17 @@ test('priority:policy build branch-protection payload honors explicit disabled s test('priority:policy --apply projects required checks from the branch-class contract when the manifest omits copied lists', async () => { const manifestOverride = JSON.parse(await readFile(new URL('../policy.json', import.meta.url), 'utf8')); delete manifestOverride.branches.develop.required_status_checks; + delete manifestOverride.branches['downstream/develop'].required_status_checks; delete manifestOverride.branches.main.required_status_checks; delete manifestOverride.branches['release/*'].required_status_checks; delete manifestOverride.rulesets.develop.required_status_checks; + delete manifestOverride.rulesets['downstream-develop'].required_status_checks; delete manifestOverride.rulesets['8614140'].required_status_checks; delete manifestOverride.rulesets['8614172'].required_status_checks; const repoUrl = 'https://api.github.com/repos/test-org/test-repo'; const branchDevelopUrl = `${repoUrl}/branches/develop/protection`; + const branchDownstreamDevelopUrl = `${repoUrl}/branches/downstream%2Fdevelop/protection`; const branchMainUrl = `${repoUrl}/branches/main/protection`; const listUrl = `${repoUrl}/rulesets`; const rulesets = createAlignedRulesets(); @@ -3142,6 +3296,9 @@ test('priority:policy --apply projects required checks from the branch-class con if (method === 'GET' && url === branchDevelopUrl) { return createResponse(createAlignedBranchProtection([])); } + if (method === 'GET' && url === branchDownstreamDevelopUrl) { + return createResponse(createAlignedBranchProtection(EXPECTED_DOWNSTREAM_DEVELOP_CHECKS)); + } if (method === 'GET' && url === branchMainUrl) { return createResponse(createAlignedBranchProtection(EXPECTED_MAIN_CHECKS)); } @@ -3166,6 +3323,9 @@ test('priority:policy --apply projects required checks from the branch-class con if (method === 'GET' && url === `${repoUrl}/rulesets/8614172`) { return createResponse(rulesets.release); } + if (method === 'GET' && url === `${repoUrl}/rulesets/${rulesets.downstreamDevelop.id}`) { + return createResponse(rulesets.downstreamDevelop); + } if (method === 'GET' && url === listUrl) { return createResponse(Object.values(rulesets).map(toRulesetSummary)); } diff --git a/tools/priority/policy.json b/tools/priority/policy.json index f45341753..2cd4d0ec8 100644 --- a/tools/priority/policy.json +++ b/tools/priority/policy.json @@ -47,13 +47,8 @@ "required_status_checks": [ "lint", "fixtures", - "session-index", - "issue-snapshot", - "semver", "Policy Guard (Upstream) / policy-guard", "vi-history-scenarios-linux", - "agent-review-policy", - "hook-parity", "commit-integrity" ] }, @@ -150,13 +145,8 @@ "required_status_checks": [ "lint", "fixtures", - "session-index", - "issue-snapshot", - "semver", "Policy Guard (Upstream) / policy-guard", "vi-history-scenarios-linux", - "agent-review-policy", - "hook-parity", "commit-integrity" ] }, From e3b725902e5980abc3e2070a66b817a9f758f738 Mon Sep 17 00:00:00 2001 From: Sergio Velderrain Date: Mon, 30 Mar 2026 14:58:43 -0700 Subject: [PATCH 03/16] Use touch-history semantics in VI history proofs (#2056) Co-authored-by: svelderrainruiz --- tests/CompareVI.History.Tests.ps1 | 143 +++++++++++++- tests/Render-VIHistoryReport.Tests.ps1 | 6 +- tools/Compare-RefsToTemp.ps1 | 25 ++- tools/Compare-VIHistory.ps1 | 264 +++++++++++++++++++------ tools/Render-VIHistoryReport.ps1 | 3 + 5 files changed, 378 insertions(+), 63 deletions(-) diff --git a/tests/CompareVI.History.Tests.ps1 b/tests/CompareVI.History.Tests.ps1 index 0a638c927..a18f58342 100644 --- a/tests/CompareVI.History.Tests.ps1 +++ b/tests/CompareVI.History.Tests.ps1 @@ -627,6 +627,68 @@ exit 0 } } + It 'does not treat compare identity banners as diff categories or highlights' { + if (-not $_pairs) { Set-ItResult -Skipped -Because 'Missing commit data'; return } + $previousDiff = $env:STUB_COMPARE_DIFF + $previousFixture = $env:STUB_COMPARE_REPORT_FIXTURE + $fixtureRoot = Join-Path $TestDrive 'history-identity-banner-fixture' + New-Item -ItemType Directory -Path $fixtureRoot -Force | Out-Null +@' + + +
+ + Block Diagram - Diagram +
    +
  • Block Diagram objects
  • +
+
+ + +'@ | Set-Content -LiteralPath (Join-Path $fixtureRoot 'compare-report.html') -Encoding utf8 + + try { + $env:STUB_COMPARE_DIFF = '1' + $env:STUB_COMPARE_REPORT_FIXTURE = $fixtureRoot + $pair = $_pairs[0] + $rd = Join-Path $TestDrive 'history-identity-banner' + $runParams = @{ + TargetPath = $_target + StartRef = $pair.Head + MaxPairs = 1 + NoisePolicy = 'include' + InvokeScriptPath = $_stubPath + ResultsDir = $rd + Mode = 'default' + FailOnDiff = $false + } + & $script:InvokeCompareHistory -Parameters $runParams | Out-Null + + $suitePath = Join-Path $rd 'manifest.json' + Test-Path -LiteralPath $suitePath | Should -BeTrue + $aggregate = Get-Content -LiteralPath $suitePath -Raw | ConvertFrom-Json + $modeEntry = $aggregate.modes | Where-Object { $_.slug -eq 'default' } + $modeManifest = Get-Content -LiteralPath $modeEntry.manifestPath -Raw | ConvertFrom-Json + $comparison = @($modeManifest.comparisons)[0] + + ($comparison.result.categories -join "`n") | Should -Not -Match 'First VI:|Second VI:|compare/m0/Base\.vi|compare/m0/Head\.vi' + ($comparison.result.highlights -join "`n") | Should -Not -Match 'First VI:|Second VI:|compare/m0/Base\.vi|compare/m0/Head\.vi' + $comparison.result.categories | Should -Contain 'Block Diagram' + $aggregate.stats.categoryCounts.PSObject.Properties.Name | Should -Contain 'Block Diagram' + } finally { + if ($null -eq $previousDiff) { + Remove-Item Env:STUB_COMPARE_DIFF -ErrorAction SilentlyContinue + } else { + $env:STUB_COMPARE_DIFF = $previousDiff + } + if ($null -eq $previousFixture) { + Remove-Item Env:STUB_COMPARE_REPORT_FIXTURE -ErrorAction SilentlyContinue + } else { + $env:STUB_COMPARE_REPORT_FIXTURE = $previousFixture + } + } + } + It 'processes full history when MaxPairs is omitted' { if (-not $_pairs) { Set-ItResult -Skipped -Because 'Missing commit data'; return } @@ -657,7 +719,7 @@ exit 0 $modeManifest = Get-Content -LiteralPath $modeEntry.manifestPath -Raw | ConvertFrom-Json $modeManifest.maxPairs | Should -BeNullOrEmpty - $modeManifest.stats.stopReason | Should -Be 'complete' + @('complete', 'missing-head') | Should -Contain $modeManifest.stats.stopReason $modeManifest.stats.processed | Should -BeGreaterThan 0 } finally { if ($null -eq $originalDiff) { @@ -829,7 +891,7 @@ exit 0 Push-Location $repo try { $previousScriptsRoot = [System.Environment]::GetEnvironmentVariable('COMPAREVI_SCRIPTS_ROOT', 'Process') - [System.Environment]::SetEnvironmentVariable('COMPAREVI_SCRIPTS_ROOT', (Join-Path $_repoRoot 'tools'), 'Process') + [System.Environment]::SetEnvironmentVariable('COMPAREVI_SCRIPTS_ROOT', $_repoRoot, 'Process') & pwsh -NoLogo -NoProfile -File (Join-Path $_repoRoot 'tools/Compare-VIHistory.ps1') ` -TargetPath 'VI1.vi' ` -StartRef $mergeCommit ` @@ -858,6 +920,81 @@ exit 0 $manifest.comparisons[0].head.ref | Should -Be $mergeCommit } + It 'builds comparison pairs from VI touch history instead of first-parent lineage' { + $repo = Join-Path $TestDrive 'history-touch-sequence' + New-Item -ItemType Directory -Path $repo -Force | Out-Null + & git -C $repo init -b main | Out-Null + & git -C $repo config user.name 'CompareVI Test' | Out-Null + & git -C $repo config user.email 'comparevi@example.test' | Out-Null + + 'base' | Set-Content -LiteralPath (Join-Path $repo 'VI1.vi') -Encoding utf8 + & git -C $repo add VI1.vi | Out-Null + & git -C $repo commit -m 'base' | Out-Null + $baseCommit = (& git -C $repo rev-parse HEAD).Trim() + + & git -C $repo checkout -b feature/history-pairs | Out-Null + 'feature change 1' | Set-Content -LiteralPath (Join-Path $repo 'VI1.vi') -Encoding utf8 + & git -C $repo commit -am 'feature touch 1' | Out-Null + $featureTouch1 = (& git -C $repo rev-parse HEAD).Trim() + + 'feature change 2' | Set-Content -LiteralPath (Join-Path $repo 'VI1.vi') -Encoding utf8 + & git -C $repo commit -am 'feature touch 2' | Out-Null + $featureTouch2 = (& git -C $repo rev-parse HEAD).Trim() + + & git -C $repo checkout main | Out-Null + 'mainline context' | Set-Content -LiteralPath (Join-Path $repo 'README.md') -Encoding utf8 + & git -C $repo add README.md | Out-Null + & git -C $repo commit -m 'mainline context' | Out-Null + & git -C $repo merge --no-ff feature/history-pairs -m 'merge feature history' | Out-Null + + 'post merge context' | Add-Content -LiteralPath (Join-Path $repo 'README.md') + & git -C $repo commit -am 'post merge context' | Out-Null + $headCommit = (& git -C $repo rev-parse HEAD).Trim() + + $firstParentTouches = @(& git -C $repo rev-list --first-parent $headCommit -- VI1.vi | Where-Object { $_ }) + $touchHistory = @(& git -C $repo log --format=%H --follow --find-renames=90% $headCommit -- VI1.vi | Where-Object { $_ }) + $touchHistory.Count | Should -BeGreaterThan $firstParentTouches.Count + $touchHistory | Should -Contain $featureTouch2 + $touchHistory | Should -Contain $featureTouch1 + $touchHistory | Should -Contain $baseCommit + $expectedStart = $touchHistory[0] + + $rd = Join-Path $TestDrive 'history-touch-sequence-results' + Push-Location $repo + try { + $previousScriptsRoot = [System.Environment]::GetEnvironmentVariable('COMPAREVI_SCRIPTS_ROOT', 'Process') + [System.Environment]::SetEnvironmentVariable('COMPAREVI_SCRIPTS_ROOT', $_repoRoot, 'Process') + & pwsh -NoLogo -NoProfile -File (Join-Path $_repoRoot 'tools/Compare-VIHistory.ps1') ` + -TargetPath 'VI1.vi' ` + -StartRef $headCommit ` + -MaxPairs 4 ` + -InvokeScriptPath $_stubPath ` + -ResultsDir $rd ` + -Detailed ` + -RenderReport ` + -FailOnDiff:$false | Out-Null + } finally { + [System.Environment]::SetEnvironmentVariable('COMPAREVI_SCRIPTS_ROOT', $previousScriptsRoot, 'Process') + Pop-Location + } + + $suitePath = Join-Path $rd 'manifest.json' + Test-Path -LiteralPath $suitePath | Should -BeTrue + $aggregate = Get-Content -LiteralPath $suitePath -Raw | ConvertFrom-Json + $modeEntry = $aggregate.modes | Where-Object { $_.slug -eq 'default' } + $modeEntry | Should -Not -BeNullOrEmpty + Test-Path -LiteralPath $modeEntry.manifestPath | Should -BeTrue + $manifest = Get-Content -LiteralPath $modeEntry.manifestPath -Raw | ConvertFrom-Json + + $manifest.requestedStartRef | Should -Be $headCommit + $manifest.startRef | Should -Be $expectedStart + $manifest.stats.processed | Should -BeGreaterThan 0 + $manifest.comparisons[0].lineage.type | Should -Be 'touch-history' + $manifest.comparisons[0].head.ref | Should -Be $touchHistory[0] + $manifest.comparisons[0].base.ref | Should -Be $touchHistory[1] + @($manifest.comparisons | ForEach-Object { $_.head.ref }) | Should -Contain $featureTouch2 + } + It 'exposes attribute-focused mode when requested' { if (-not $_pairs) { Set-ItResult -Skipped -Because 'Missing commit data'; return } $env:STUB_COMPARE_DIFF = '0' @@ -1657,6 +1794,7 @@ Describe 'Compare-VIHistory source control handling' -Tag 'Integration' { } It 'detects when SCC is enabled in LabVIEW.ini' { + if (-not $IsWindows) { Set-ItResult -Skipped -Because 'LabVIEW.ini lookup is only supported on Windows'; return } $tempRoot = Join-Path $TestDrive 'lv-scc-enabled' New-Item -ItemType Directory -Path $tempRoot | Out-Null $fakeExe = Join-Path $tempRoot 'LabVIEW.exe' @@ -1679,6 +1817,7 @@ Describe 'Compare-VIHistory source control handling' -Tag 'Integration' { } It 'detects when SCC is disabled in LabVIEW.ini' { + if (-not $IsWindows) { Set-ItResult -Skipped -Because 'LabVIEW.ini lookup is only supported on Windows'; return } $tempRoot = Join-Path $TestDrive 'lv-scc-disabled' New-Item -ItemType Directory -Path $tempRoot | Out-Null $fakeExe = Join-Path $tempRoot 'LabVIEW.exe' diff --git a/tests/Render-VIHistoryReport.Tests.ps1 b/tests/Render-VIHistoryReport.Tests.ps1 index c90a3271a..d1273d82a 100644 --- a/tests/Render-VIHistoryReport.Tests.ps1 +++ b/tests/Render-VIHistoryReport.Tests.ps1 @@ -115,14 +115,14 @@ Describe 'Render-VIHistoryReport.ps1' -Tag 'Unit' { subject= 'Clean head commit' } lineage = [ordered]@{ - type = 'mainline' + type = 'touch-history' parentIndex = 1 parentCount = 1 mergeCommit = $null branchHead = $null depth = 0 } - lineageLabel = 'Mainline' + lineageLabel = 'Touch history' result = [ordered]@{ diff = $false duration_s = 0.45 @@ -218,6 +218,7 @@ Describe 'Render-VIHistoryReport.ps1' -Tag 'Unit' { $markdown | Should -Match '\| Outcome Labels \| `clean`, `signal-diff` \|' $markdown | Should -Match '\| Mode \| Processed \| Diffs \| Signal \| Collapsed Noise \| Missing \| Categories \| Buckets \| Flags \|' $markdown | Should -Match '\| Mode \| Pair \| Lineage \| Base \| Head \| Diff \| Duration \(s\) \| Categories \| Buckets \| Report \| Highlights \|' + $markdown | Should -Match 'Touch history' $html = Get-Content -LiteralPath $htmlPath -Raw $html | Should -Match 'Source branch' @@ -238,6 +239,7 @@ Describe 'Render-VIHistoryReport.ps1' -Tag 'Unit' { $html | Should -Match 'Lineage' $html | Should -Match 'Categories' $html | Should -Match 'Buckets' + $html | Should -Match 'Touch history' $html | Should -Match 'data-buckets=' $html | Should -Match 'Functional behavior \(1\)' diff --git a/tools/Compare-RefsToTemp.ps1 b/tools/Compare-RefsToTemp.ps1 index f22ad8dd9..68952004f 100644 --- a/tools/Compare-RefsToTemp.ps1 +++ b/tools/Compare-RefsToTemp.ps1 @@ -164,9 +164,10 @@ function Parse-DiffHeadings { $raw = $match.Groups['text'].Value if ([string]::IsNullOrWhiteSpace($raw)) { continue } - $decoded = [System.Net.WebUtility]::HtmlDecode($raw.Trim()) + $decoded = Normalize-ComparisonReportText -Value $raw $decoded = ($decoded -replace '^\s*\d+[\.\)]\s*', '') if ([string]::IsNullOrWhiteSpace($decoded)) { continue } + if (Test-IsComparisonIdentityLabel -Value $decoded) { continue } if (-not $headings.Contains($decoded)) { $headings.Add($decoded) | Out-Null } @@ -176,6 +177,25 @@ function Parse-DiffHeadings { return @($headings.ToArray()) } +function Normalize-ComparisonReportText { + param([string]$Value) + + if ([string]::IsNullOrWhiteSpace($Value)) { return '' } + + $decoded = [System.Net.WebUtility]::HtmlDecode($Value) + $withoutTags = [regex]::Replace($decoded, '<[^>]+>', ' ') + return ([regex]::Replace($withoutTags, '\s+', ' ')).Trim() +} + +function Test-IsComparisonIdentityLabel { + param([string]$Value) + + $normalized = Normalize-ComparisonReportText -Value $Value + if ([string]::IsNullOrWhiteSpace($normalized)) { return $false } + + return ($normalized -match '^\s*First\s+VI:\s*.+?\s+Second\s+VI:\s*.+?\s*$') +} + function Parse-DiffDetails { param([string]$Html) @@ -186,7 +206,8 @@ function Parse-DiffDetails { foreach ($match in [System.Text.RegularExpressions.Regex]::Matches($Html, $pattern, 'IgnoreCase')) { $raw = $match.Groups['text'].Value if ([string]::IsNullOrWhiteSpace($raw)) { continue } - $decoded = [System.Net.WebUtility]::HtmlDecode($raw.Trim()) + $decoded = Normalize-ComparisonReportText -Value $raw + if (Test-IsComparisonIdentityLabel -Value $decoded) { continue } if ($decoded) { $details.Add($decoded) | Out-Null } diff --git a/tools/Compare-VIHistory.ps1 b/tools/Compare-VIHistory.ps1 index d669c5caf..c49521807 100644 --- a/tools/Compare-VIHistory.ps1 +++ b/tools/Compare-VIHistory.ps1 @@ -237,6 +237,29 @@ function Get-ComparisonCategories { return @($categories.ToArray()) } +function Normalize-ComparisonReportText { + param([string]$Value) + + if ([string]::IsNullOrWhiteSpace($Value)) { + return '' + } + + $decoded = [System.Net.WebUtility]::HtmlDecode($Value) + $withoutTags = [regex]::Replace($decoded, '<[^>]+>', ' ') + return ([regex]::Replace($withoutTags, '\s+', ' ')).Trim() +} + +function Test-IsComparisonIdentityLabel { + param([string]$Value) + + $normalized = Normalize-ComparisonReportText -Value $Value + if ([string]::IsNullOrWhiteSpace($normalized)) { + return $false + } + + return ($normalized -match '^\s*First\s+VI:\s*.+?\s+Second\s+VI:\s*.+?\s*$') +} + function Parse-ReportDiffHeadings { param([string]$Html) @@ -258,9 +281,10 @@ function Parse-ReportDiffHeadings { foreach ($match in [System.Text.RegularExpressions.Regex]::Matches($Html, $pattern, $regexOptions)) { $raw = $match.Groups['text'].Value if ([string]::IsNullOrWhiteSpace($raw)) { continue } - $decoded = [System.Net.WebUtility]::HtmlDecode($raw.Trim()) + $decoded = Normalize-ComparisonReportText -Value $raw $decoded = ($decoded -replace '^\s*\d+[\.\)]\s*', '') if ([string]::IsNullOrWhiteSpace($decoded)) { continue } + if (Test-IsComparisonIdentityLabel -Value $decoded) { continue } if (-not $headings.Contains($decoded)) { $headings.Add($decoded) | Out-Null } @@ -280,7 +304,8 @@ function Parse-ReportDiffDetails { foreach ($match in [System.Text.RegularExpressions.Regex]::Matches($Html, $pattern, 'IgnoreCase')) { $raw = $match.Groups['text'].Value if ([string]::IsNullOrWhiteSpace($raw)) { continue } - $decoded = [System.Net.WebUtility]::HtmlDecode($raw.Trim()) + $decoded = Normalize-ComparisonReportText -Value $raw + if (Test-IsComparisonIdentityLabel -Value $decoded) { continue } if ($decoded) { $details.Add($decoded) | Out-Null } @@ -739,6 +764,30 @@ function Test-CommitTouchesPath { return -not [string]::IsNullOrWhiteSpace($result) } +function Get-TouchHistoryCommits { + param( + [Parameter(Mandatory = $true)][string]$Ref, + [Parameter(Mandatory = $true)][string]$Path + ) + + if ([string]::IsNullOrWhiteSpace($Ref) -or [string]::IsNullOrWhiteSpace($Path)) { + return @() + } + + $raw = Invoke-Git -Arguments @('log','--format=%H','--follow','--find-renames=90%',$Ref,'--',$Path) -Quiet + $commits = New-Object System.Collections.Generic.List[string] + $seen = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + foreach ($line in ($raw -split "`n")) { + $commit = $line.Trim() + if ([string]::IsNullOrWhiteSpace($commit)) { continue } + if ($seen.Add($commit)) { + $commits.Add($commit) | Out-Null + } + } + + return $commits.ToArray() +} + function Get-CommitParents { param( [Parameter(Mandatory = $true)][string]$Commit @@ -930,34 +979,20 @@ function Get-MergeParentPlan { function Build-ComparisonPlan { param( - [Parameter(Mandatory = $true)][string[]]$MainlineCommits, + [Parameter(Mandatory = $true)][string[]]$TouchCommits, [Parameter(Mandatory = $true)][string]$TargetRel, [string]$EndRef, [switch]$IncludeMergeParents ) - $mainlineSet = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) - $mainlineList = New-Object System.Collections.Generic.List[string] - foreach ($entry in $MainlineCommits) { + $touchSet = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + $touchList = New-Object System.Collections.Generic.List[string] + foreach ($entry in $TouchCommits) { if ([string]::IsNullOrWhiteSpace($entry)) { continue } $trimmed = $entry.Trim() if ([string]::IsNullOrWhiteSpace($trimmed)) { continue } - if ($mainlineSet.Add($trimmed)) { - $mainlineList.Add($trimmed) - } - } - - for ($index = 0; $index -lt $mainlineList.Count; $index++) { - $currentCommit = $mainlineList[$index] - $parentsForCurrent = @(Get-CommitParents -Commit $currentCommit) - if (-not $parentsForCurrent -or $parentsForCurrent.Count -eq 0) { continue } - $firstParentForCurrent = $parentsForCurrent[0] - if ([string]::IsNullOrWhiteSpace($firstParentForCurrent)) { continue } - if ($mainlineSet.Add($firstParentForCurrent)) { - $mainlineList.Add($firstParentForCurrent) - } - if ($EndRef -and [string]::Equals($firstParentForCurrent, $EndRef, [System.StringComparison]::OrdinalIgnoreCase)) { - break + if ($touchSet.Add($trimmed)) { + $touchList.Add($trimmed) } } @@ -970,18 +1005,33 @@ function Build-ComparisonPlan { function Add-Spec { param([object]$Spec, [System.Collections.Generic.HashSet[string]]$KeySet, [System.Collections.Generic.List[object]]$PlanList) if (-not $Spec) { return } - if ([string]::IsNullOrWhiteSpace($Spec.Head) -or [string]::IsNullOrWhiteSpace($Spec.Base)) { return } - - $lineage = $Spec.Lineage - $parentIndexKey = if ($lineage -and $lineage.PSObject.Properties['parentIndex']) { [int]$lineage.parentIndex } else { 0 } - $mergeCommitKey = if ($lineage -and $lineage.PSObject.Properties['mergeCommit']) { [string]$lineage.mergeCommit } else { '' } - $depthKey = if ($lineage -and $lineage.PSObject.Properties['depth']) { [int]$lineage.depth } else { 0 } - $key = "{0}|{1}|{2}|{3}|{4}" -f $Spec.Head, $Spec.Base, $parentIndexKey, $mergeCommitKey, $depthKey + $headValue = if ($Spec -is [System.Collections.IDictionary]) { [string]$Spec['Head'] } else { [string]$Spec.Head } + $baseValue = if ($Spec -is [System.Collections.IDictionary]) { [string]$Spec['Base'] } else { [string]$Spec.Base } + if ([string]::IsNullOrWhiteSpace($headValue) -or [string]::IsNullOrWhiteSpace($baseValue)) { return } + + $lineage = if ($Spec -is [System.Collections.IDictionary]) { $Spec['Lineage'] } else { $Spec.Lineage } + $parentIndexKey = if ($lineage -is [System.Collections.IDictionary]) { + if ($lineage.Contains('parentIndex')) { [int]$lineage['parentIndex'] } else { 0 } + } elseif ($lineage -and $lineage.PSObject.Properties['parentIndex']) { + [int]$lineage.parentIndex + } else { 0 } + $mergeCommitKey = if ($lineage -is [System.Collections.IDictionary]) { + if ($lineage.Contains('mergeCommit')) { [string]$lineage['mergeCommit'] } else { '' } + } elseif ($lineage -and $lineage.PSObject.Properties['mergeCommit']) { + [string]$lineage.mergeCommit + } else { '' } + $depthKey = if ($lineage -is [System.Collections.IDictionary]) { + if ($lineage.Contains('depth')) { [int]$lineage['depth'] } else { 0 } + } elseif ($lineage -and $lineage.PSObject.Properties['depth']) { + [int]$lineage.depth + } else { 0 } + $key = "{0}|{1}|{2}|{3}|{4}" -f $headValue, $baseValue, $parentIndexKey, $mergeCommitKey, $depthKey if (-not $KeySet.Add($key)) { return } $PlanList.Add([pscustomobject]$Spec) | Out-Null } - foreach ($rawHead in $mainlineList) { + for ($index = 0; $index -lt $touchList.Count; $index++) { + $rawHead = $touchList[$index] $head = $rawHead.Trim() if (-not $head) { continue } @@ -998,13 +1048,49 @@ function Build-ComparisonPlan { $parentCount = $parents.Count $firstParent = $parents[0] + $nextTouch = $null + if (($index + 1) -lt $touchList.Count) { + $nextTouch = $touchList[$index + 1] + } + + $baseCommit = $null + $stopAfter = $false + $lineageType = 'touch-history' + + if ($EndRef -and $nextTouch -and [string]::Equals($nextTouch, $EndRef, [System.StringComparison]::OrdinalIgnoreCase)) { + $baseCommit = $nextTouch + $stopAfter = $true + } elseif ($EndRef -and (Test-IsAncestorSafe -Ancestor $EndRef -Descendant $head)) { + if ($nextTouch) { + if (Test-IsAncestorSafe -Ancestor $nextTouch -Descendant $EndRef) { + $baseCommit = $EndRef + $stopAfter = $true + } + } else { + $baseCommit = $EndRef + $stopAfter = $true + } + } + + if (-not $baseCommit) { + if ($nextTouch) { + $baseCommit = $nextTouch + } else { + $baseCommit = $firstParent + $lineageType = 'mainline' + } + } + + if ([string]::IsNullOrWhiteSpace($baseCommit)) { + $terminalHint = 'reached-root' + break + } - $stopAfter = $EndRef -and [string]::Equals($firstParent, $EndRef, [System.StringComparison]::OrdinalIgnoreCase) $spec = [ordered]@{ Head = $head - Base = $firstParent + Base = $baseCommit Lineage = [ordered]@{ - type = 'mainline' + type = $lineageType parentIndex = 1 parentCount = $parentCount mergeCommit = $head @@ -1030,7 +1116,7 @@ function Build-ComparisonPlan { if ($stopAfter) { break } } - return [ordered]@{ + return [pscustomobject]@{ Plan = $plan.ToArray() TerminalHint = $terminalHint } @@ -1054,6 +1140,45 @@ function Test-IsAncestor { throw ("git merge-base --is-ancestor failed: {0}" -f $result.StdErr) } +function Test-IsAncestorSafe { + param( + [Parameter(Mandatory = $true)][string]$Ancestor, + [Parameter(Mandatory = $true)][string]$Descendant + ) + + try { + return Test-IsAncestor -Ancestor $Ancestor -Descendant $Descendant + } catch { + return $false + } +} + +function Select-TouchHistoryWindow { + param( + [Parameter(Mandatory = $true)][string[]]$TouchCommits, + [Parameter(Mandatory = $true)][string]$StartCommit + ) + + $selected = New-Object System.Collections.Generic.List[string] + $startFound = $false + foreach ($rawCommit in $TouchCommits) { + $commit = $rawCommit.Trim() + if ([string]::IsNullOrWhiteSpace($commit)) { continue } + if (-not $startFound) { + if (-not [string]::Equals($commit, $StartCommit, [System.StringComparison]::OrdinalIgnoreCase)) { + continue + } + $startFound = $true + } + $selected.Add($commit) | Out-Null + } + + return [pscustomobject]@{ + Commits = $selected.ToArray() + StartFound = $startFound + } +} + function Resolve-CommitWithChange { param( [Parameter(Mandatory = $true)][string]$StartRef, @@ -1065,27 +1190,30 @@ function Resolve-CommitWithChange { return $StartRef } - $upRaw = Invoke-Git -Arguments @('rev-list','--first-parent',"$StartRef..$HeadRef",'--',$Path) -Quiet - $upList = @($upRaw -split "`n" | Where-Object { $_ }) - if ($upList.Count -gt 0) { - for ($i = $upList.Count - 1; $i -ge 0; $i--) { - $commit = $upList[$i] - if (Test-IsAncestor -Ancestor $StartRef -Descendant $commit) { - return $commit + $headTouchHistory = @(Get-TouchHistoryCommits -Ref $HeadRef -Path $Path) + if ($headTouchHistory.Count -gt 0) { + $nearestNewer = $null + foreach ($commit in $headTouchHistory) { + if (Test-IsAncestorSafe -Ancestor $StartRef -Descendant $commit) { + $nearestNewer = $commit } } - } + if ($nearestNewer) { + return $nearestNewer + } - $downRaw = Invoke-Git -Arguments @('rev-list','--first-parent',$StartRef,'--',$Path) -Quiet - $downList = @($downRaw -split "`n" | Where-Object { $_ }) - if ($downList.Count -gt 0) { - foreach ($commit in $downList) { - if (Test-CommitTouchesPath -Commit $commit -Path $Path) { + foreach ($commit in $headTouchHistory) { + if (Test-IsAncestorSafe -Ancestor $commit -Descendant $StartRef) { return $commit } } } + $downList = @(Get-TouchHistoryCommits -Ref $StartRef -Path $Path) + if ($downList.Count -gt 0) { + return $downList[0] + } + return $null } @@ -1348,16 +1476,30 @@ Write-Verbose ("StartRef before ensure: {0}; Target: {1}" -f $startRef, $targetR Ensure-FileExistsAtRef -Ref $startRef -Path $targetRel if ($endRef) { Ensure-FileExistsAtRef -Ref $endRef -Path $targetRel } -$revArgs = @('rev-list','--first-parent',$startRef) -if ($maxPairsRequested) { - $revArgs += ("--max-count={0}" -f ([int]($MaxPairs + 5))) +$allTouchCommits = @(Get-TouchHistoryCommits -Ref 'HEAD' -Path $targetRel) +$touchWindow = Select-TouchHistoryWindow -TouchCommits $allTouchCommits -StartCommit $startRef +$commitList = @() +if ($touchWindow.StartFound) { + $commitList = @($touchWindow.Commits) +} elseif (Test-CommitTouchesPath -Commit $startRef -Path $targetRel) { + $olderTouches = New-Object System.Collections.Generic.List[string] + foreach ($commit in $allTouchCommits) { + if (Test-IsAncestorSafe -Ancestor $commit -Descendant $startRef) { + $olderTouches.Add($commit) | Out-Null + } + } + $commitList = @($startRef) + if ($olderTouches.Count -gt 0) { + $commitList += @($olderTouches.ToArray()) + } +} else { + $commitList = @(Get-TouchHistoryCommits -Ref $startRef -Path $targetRel) +} +if ($maxPairsRequested -and $commitList.Count -gt ($MaxPairs + 5)) { + $commitList = @($commitList | Select-Object -First ([int]($MaxPairs + 5))) } -$revArgs += '--' -$revArgs += $targetRel -$revListRaw = Invoke-Git -Arguments $revArgs -Quiet -$commitList = @($revListRaw -split "`n" | Where-Object { $_ }) Write-Verbose ("Commit list count: {0}" -f $commitList.Count) -$planResult = Build-ComparisonPlan -MainlineCommits $commitList -TargetRel $targetRel -EndRef $endRef -IncludeMergeParents:$IncludeMergeParents.IsPresent +$planResult = Build-ComparisonPlan -TouchCommits $commitList -TargetRel $targetRel -EndRef $endRef -IncludeMergeParents:$IncludeMergeParents.IsPresent $comparisonPlan = @() $planTerminalHint = $null if ($planResult) { @@ -1916,10 +2058,18 @@ foreach ($modeSpec in $modeSpecs) { $cliCategoryBuckets = @() $cliCategoryBucketDetails = @() if ($summaryJson.cli -and $summaryJson.cli.PSObject.Properties['highlights'] -and $summaryJson.cli.highlights) { - $highlights += @($summaryJson.cli.highlights | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + $highlights += @( + $summaryJson.cli.highlights | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + Where-Object { -not (Test-IsComparisonIdentityLabel -Value ([string]$_)) } + ) } if ($summaryJson.cli -and $summaryJson.cli.PSObject.Properties['categories'] -and $summaryJson.cli.categories) { - $cliCategories += @($summaryJson.cli.categories | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + $cliCategories += @( + $summaryJson.cli.categories | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + Where-Object { -not (Test-IsComparisonIdentityLabel -Value ([string]$_)) } + ) } if ($summaryJson.cli -and $summaryJson.cli.PSObject.Properties['categoryDetails'] -and $summaryJson.cli.categoryDetails) { $cliCategoryDetails += @($summaryJson.cli.categoryDetails) diff --git a/tools/Render-VIHistoryReport.ps1 b/tools/Render-VIHistoryReport.ps1 index c88a448c8..143bf7a98 100644 --- a/tools/Render-VIHistoryReport.ps1 +++ b/tools/Render-VIHistoryReport.ps1 @@ -126,6 +126,9 @@ function Get-LineageLabel { $rootMerge = if ($Lineage.PSObject.Properties['rootMerge']) { [string]$Lineage.rootMerge } else { $mergeCommit } switch ($type.ToLowerInvariant()) { + 'touch-history' { + return 'Touch history' + } 'merge-parent' { $label = if ($parentIndex -and $parentIndex -gt 0) { "Merge parent #$parentIndex" } else { 'Merge parent' } if ($depth -and $depth -gt 0) { From 809ba89153b5fa61bf387093461a87843a7ccad6 Mon Sep 17 00:00:00 2001 From: Sergio Velderrain Date: Mon, 30 Mar 2026 15:48:40 -0700 Subject: [PATCH 04/16] Preserve VI history category specificity (#2057) Co-authored-by: svelderrainruiz --- tests/CompareVI.History.Tests.ps1 | 13 +++- tests/VICategoryBuckets.Tests.ps1 | 8 +++ tools/Compare-RefsToTemp.ps1 | 82 ++++++++++++++++++++++-- tools/Compare-VIHistory.ps1 | 101 +++++++++++++++++++++++++++--- tools/VICategoryBuckets.psm1 | 28 +++++---- 5 files changed, 203 insertions(+), 29 deletions(-) diff --git a/tests/CompareVI.History.Tests.ps1 b/tests/CompareVI.History.Tests.ps1 index a18f58342..2808afe73 100644 --- a/tests/CompareVI.History.Tests.ps1 +++ b/tests/CompareVI.History.Tests.ps1 @@ -512,6 +512,7 @@ exit 0 $aggregate.stats.signalDiffs | Should -Be 0 $aggregate.stats.noiseCollapsed | Should -BeGreaterThan 0 $aggregate.stats.categoryCounts.PSObject.Properties.Name | Should -Contain 'VI Attribute' + $aggregate.stats.categoryCounts.PSObject.Properties.Name | Should -Not -Contain 'Cosmetic' $aggregate.stats.categoryCounts.PSObject.Properties.Name | Should -Not -Contain 'unspecified' [int]$aggregate.stats.bucketCounts.metadata | Should -BeGreaterThan 0 $outputText | Should -Match 'LVCompare detected differences' @@ -766,6 +767,7 @@ exit 0 $manifest.flags | Should -Contain '-nobdcosm' $manifest.stats.stopReason | Should -Be 'max-pairs' $manifest.comparisons.Count | Should -Be 0 + $manifest.stats.categoryCounts.PSObject.Properties.Name | Should -Not -Contain 'Cosmetic' } It 'captures xml report when alternate format requested' { @@ -1513,11 +1515,12 @@ exit 0 FixtureRel = Join-Path 'fixtures' 'vi-report' 'block-diagram' ExpectPattern = 'Block Diagram Cosmetic' ExpectedCategories = @('cosmetic') + ExpectedDetailSlug = 'block-diagram-cosmetic' } ) It "surfaces highlights when suppression is removed ()" -TestCases $fixtureCases { - param($Name, $Param, $FixtureRel, $ExpectPattern, $ExpectedCategories) + param($Name, $Param, $FixtureRel, $ExpectPattern, $ExpectedCategories, $ExpectedDetailSlug) if (-not $_pairs) { Set-ItResult -Skipped -Because 'Missing commit data'; return } $pair = $_pairs[0] @@ -1581,6 +1584,14 @@ exit 0 if ($targetFlag) { ($variantManifest.flags -contains $targetFlag) | Should -BeFalse } + if (-not [string]::IsNullOrWhiteSpace($ExpectedDetailSlug)) { + $variantComparison = @($variantManifest.comparisons)[0] + $variantComparison | Should -Not -BeNullOrEmpty + $variantComparison.result.categories | Should -Contain $ExpectPattern + $detailSlugs = @($variantComparison.result.categoryDetails | ForEach-Object { [string]$_.slug }) + $detailSlugs | Should -Contain $ExpectedDetailSlug + $detailSlugs | Should -Not -Contain 'cosmetic' + } $historyReport = Get-Content -LiteralPath (Join-Path $variantDir 'history-report.md') -Raw $historyReport | Should -Match '\| Metric \| Value \|' diff --git a/tests/VICategoryBuckets.Tests.ps1 b/tests/VICategoryBuckets.Tests.ps1 index 56db0183f..ab7693b4f 100644 --- a/tests/VICategoryBuckets.Tests.ps1 +++ b/tests/VICategoryBuckets.Tests.ps1 @@ -14,6 +14,14 @@ Describe 'VICategoryBuckets module' -Tag 'Unit' { $meta.bucketClassification | Should -Be 'neutral' } + It 'preserves known slug inputs without degrading category specificity' { + $meta = Get-VICategoryMetadata -Name 'block-diagram-cosmetic' + $meta | Should -Not -BeNullOrEmpty + $meta.slug | Should -Be 'block-diagram-cosmetic' + $meta.label | Should -Be 'Block diagram (cosmetic)' + $meta.bucketSlug | Should -Be 'ui-visual' + } + It 'collects bucket details for multiple categories' { $inputCategories = @( 'Block Diagram Functional', diff --git a/tools/Compare-RefsToTemp.ps1 b/tools/Compare-RefsToTemp.ps1 index 68952004f..09e6cd416 100644 --- a/tools/Compare-RefsToTemp.ps1 +++ b/tools/Compare-RefsToTemp.ps1 @@ -28,12 +28,6 @@ $ErrorActionPreference = 'Stop' try { git --version | Out-Null } catch { throw 'git is required on PATH to fetch file content at refs.' } $repoRoot = (Get-Location).Path -try { - $categoryModule = Join-Path $repoRoot 'tools' 'VICategoryBuckets.psm1' - if (Test-Path -LiteralPath $categoryModule -PathType Leaf) { - Import-Module $categoryModule -Force - } -} catch {} function Resolve-CompareVIScriptsRoot { param([string]$PrimaryRoot) @@ -55,6 +49,21 @@ function Resolve-CompareVIScriptsRoot { return $PrimaryRoot } +try { + $categoryModuleCandidates = New-Object System.Collections.Generic.List[string] + $categoryModuleCandidates.Add((Join-Path $repoRoot 'tools' 'VICategoryBuckets.psm1')) | Out-Null + $resolvedScriptsRoot = Resolve-CompareVIScriptsRoot -PrimaryRoot $repoRoot + if (-not [string]::IsNullOrWhiteSpace($resolvedScriptsRoot)) { + $categoryModuleCandidates.Add((Join-Path $resolvedScriptsRoot 'tools' 'VICategoryBuckets.psm1')) | Out-Null + } + foreach ($candidate in @($categoryModuleCandidates | Select-Object -Unique)) { + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + Import-Module $candidate -Force + break + } + } +} catch {} + function Split-ArgString { param([string]$Value) if ([string]::IsNullOrWhiteSpace($Value)) { return @() } @@ -275,6 +284,61 @@ function Infer-DiffCategoriesFromDetails { return @($inferred.ToArray()) } +function Normalize-ReportCategories { + param([System.Collections.IEnumerable]$Categories) + + if (-not $Categories -or -not (Get-Command -Name Get-VICategoryBuckets -ErrorAction SilentlyContinue)) { + return @( + $Categories | + Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) } | + Select-Object -Unique + ) + } + + $categoryInfo = Get-VICategoryBuckets -Names @($Categories) + if ($null -eq $categoryInfo -or -not $categoryInfo.Details) { + return @( + $Categories | + Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) } | + Select-Object -Unique + ) + } + + $details = @($categoryInfo.Details) + if ($details.Count -gt 1) { + $specificDetails = @($details | Where-Object { [string]$_.slug -ne 'cosmetic' }) + if ($specificDetails.Count -gt 0) { + $details = $specificDetails + } + } + + return @( + $details | + ForEach-Object { + switch ([string]$_.slug) { + 'block-diagram' { 'Block Diagram' } + 'block-diagram-functional' { 'Block Diagram Functional' } + 'block-diagram-cosmetic' { 'Block Diagram Cosmetic' } + 'connector-pane' { 'Connector Pane' } + 'front-panel' { 'Front Panel' } + 'front-panel-position-size' { 'Front Panel Position/Size' } + 'control-changes' { 'Front Panel Controls' } + 'window' { 'Window Properties' } + 'attributes' { 'Attributes' } + 'vi-attribute' { 'VI Attribute' } + 'documentation' { 'Documentation' } + 'execution' { 'Execution Settings' } + 'icon' { 'Icon' } + 'unspecified' { 'Unspecified' } + 'cosmetic' { 'Cosmetic' } + default { if ([string]::IsNullOrWhiteSpace([string]$_.label)) { [string]$_.slug } else { [string]$_.label } } + } + } | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + Select-Object -Unique + ) +} + function Get-ReportCategoryMetadata { param([string]$ReportPath) @@ -337,6 +401,12 @@ function Get-ReportCategoryMetadata { } } + $normalizedCategories = @(Normalize-ReportCategories -Categories @($categories.ToArray())) + $categories = New-Object System.Collections.Generic.List[string] + foreach ($name in $normalizedCategories) { + $categories.Add([string]$name) | Out-Null + } + $categoryDetails = @() $categoryBuckets = @() $categoryBucketDetails = @() diff --git a/tools/Compare-VIHistory.ps1 b/tools/Compare-VIHistory.ps1 index c49521807..2d6a7b6fa 100644 --- a/tools/Compare-VIHistory.ps1 +++ b/tools/Compare-VIHistory.ps1 @@ -435,6 +435,12 @@ function Get-ComparisonCategoriesFromReport { } } + $normalizedCategories = @(Normalize-ComparisonCategoryList -Categories @($categories.ToArray())) + $categories = New-Object System.Collections.Generic.List[string] + foreach ($name in $normalizedCategories) { + $categories.Add([string]$name) | Out-Null + } + $categoryDetails = @() $categoryBuckets = @() $categoryBucketDetails = @() @@ -505,6 +511,81 @@ function Get-ComparisonCategoryDisplayName { return $Name } +function Get-CanonicalComparisonCategoryName { + param([string]$Name) + + if ([string]::IsNullOrWhiteSpace($Name)) { return $null } + + $meta = $null + try { + $meta = Get-VICategoryMetadata -Name $Name + } catch { + $meta = $null + } + + if ($meta) { + switch ([string]$meta.slug) { + 'block-diagram' { return 'Block Diagram' } + 'block-diagram-functional' { return 'Block Diagram Functional' } + 'block-diagram-cosmetic' { return 'Block Diagram Cosmetic' } + 'connector-pane' { return 'Connector Pane' } + 'front-panel' { return 'Front Panel' } + 'front-panel-position-size' { return 'Front Panel Position/Size' } + 'control-changes' { return 'Front Panel Controls' } + 'window' { return 'Window Properties' } + 'attributes' { return 'Attributes' } + 'vi-attribute' { return 'VI Attribute' } + 'documentation' { return 'Documentation' } + 'execution' { return 'Execution Settings' } + 'icon' { return 'Icon' } + 'unspecified' { return 'Unspecified' } + 'cosmetic' { return 'Cosmetic' } + } + + if (-not [string]::IsNullOrWhiteSpace([string]$meta.label)) { + return [string]$meta.label + } + } + + return [string]$Name +} + +function Normalize-ComparisonCategoryList { + param([System.Collections.IEnumerable]$Categories) + + if (-not $Categories) { return @() } + + $categoryInfo = $null + try { + $categoryInfo = Get-VICategoryBuckets -Names @($Categories) + } catch { + $categoryInfo = $null + } + + if ($null -eq $categoryInfo -or -not $categoryInfo.Details) { + return @( + $Categories | + Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) } | + Select-Object -Unique + ) + } + + $details = @($categoryInfo.Details) + if ($details.Count -gt 1) { + $specificDetails = @($details | Where-Object { [string]$_.slug -ne 'cosmetic' }) + if ($specificDetails.Count -gt 0) { + $details = $specificDetails + } + } + + return @( + $details | + ForEach-Object { Get-CanonicalComparisonCategoryName -Name ([string]$_.slug) } | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + Select-Object -Unique + ) +} + function Update-TallyFromDetails { param( [System.Collections.IDictionary]$Target, @@ -2102,6 +2183,7 @@ foreach ($modeSpec in $modeSpecs) { $outNode = $summaryJson.out if ((@($cliCategories).Count -eq 0 -or @($cliCategoryDetails).Count -eq 0) -and $outNode) { $reportCandidate = $null + $reportCategoryMetadata = $null if ($outNode.PSObject.Properties['reportHtml'] -and $outNode.reportHtml) { $reportCandidate = [string]$outNode.reportHtml } elseif ($outNode.PSObject.Properties['reportPath'] -and $outNode.reportPath) { @@ -2145,29 +2227,30 @@ foreach ($modeSpec in $modeSpecs) { Select-Object -Unique ) } + $categories = @(Normalize-ComparisonCategoryList -Categories $categories) $categoryInfo = $null if (@($categories).Count -gt 0) { $categoryInfo = Get-VICategoryBuckets -Names $categories } - $resolvedCategoryDetails = if (@($cliCategoryDetails).Count -gt 0) { - @($cliCategoryDetails) - } elseif ($categoryInfo -and $categoryInfo.Details) { + $resolvedCategoryDetails = if ($categoryInfo -and $categoryInfo.Details) { @($categoryInfo.Details) + } elseif (@($cliCategoryDetails).Count -gt 0) { + @($cliCategoryDetails) } else { @() } - $resolvedCategoryBuckets = if (@($cliCategoryBuckets).Count -gt 0) { - @($cliCategoryBuckets | Select-Object -Unique) - } elseif ($categoryInfo -and $categoryInfo.BucketSlugs) { + $resolvedCategoryBuckets = if ($categoryInfo -and $categoryInfo.BucketSlugs) { @($categoryInfo.BucketSlugs) + } elseif (@($cliCategoryBuckets).Count -gt 0) { + @($cliCategoryBuckets | Select-Object -Unique) } else { @() } - $resolvedCategoryBucketDetails = if (@($cliCategoryBucketDetails).Count -gt 0) { - @($cliCategoryBucketDetails) - } elseif ($categoryInfo -and $categoryInfo.BucketDetails) { + $resolvedCategoryBucketDetails = if ($categoryInfo -and $categoryInfo.BucketDetails) { @($categoryInfo.BucketDetails) + } elseif (@($cliCategoryBucketDetails).Count -gt 0) { + @($cliCategoryBucketDetails) } else { @() } diff --git a/tools/VICategoryBuckets.psm1 b/tools/VICategoryBuckets.psm1 index e839735b7..9284566c2 100644 --- a/tools/VICategoryBuckets.psm1 +++ b/tools/VICategoryBuckets.psm1 @@ -105,19 +105,21 @@ function Resolve-VICategorySlug { if ([string]::IsNullOrWhiteSpace($Name)) { return $null } $token = $Name.Trim().ToLowerInvariant() - - if ($token -match 'block diagram' -and $token -match 'cosmetic') { return 'block-diagram-cosmetic' } - if ($token -match 'block diagram' -and $token -match 'functional') { return 'block-diagram-functional' } - if ($token -match 'block diagram') { return 'block-diagram' } - if ($token -match 'connector') { return 'connector-pane' } - if ($token -match 'vi attribute' -or $token -match 'attributes') { return 'vi-attribute' } - if ($token -match 'front panel position') { return 'front-panel-position-size' } - if ($token -match 'front panel' -or $token -match 'control changes') { return 'front-panel' } - if ($token -match 'cosmetic') { return 'cosmetic' } - if ($token -match 'window') { return 'window' } - if ($token -match 'icon') { return 'icon' } - if ($token -match 'documentation') { return 'documentation' } - if ($token -match 'execution') { return 'execution' } + if ($script:CategoryDefinitions.ContainsKey($token)) { return $token } + $normalizedToken = $token -replace '[-_]+', ' ' + + if ($normalizedToken -match 'block diagram' -and $normalizedToken -match 'cosmetic') { return 'block-diagram-cosmetic' } + if ($normalizedToken -match 'block diagram' -and $normalizedToken -match 'functional') { return 'block-diagram-functional' } + if ($normalizedToken -match 'block diagram') { return 'block-diagram' } + if ($normalizedToken -match 'connector') { return 'connector-pane' } + if ($normalizedToken -match 'vi attribute' -or $normalizedToken -match 'attributes') { return 'vi-attribute' } + if ($normalizedToken -match 'front panel position') { return 'front-panel-position-size' } + if ($normalizedToken -match 'front panel' -or $normalizedToken -match 'control changes') { return 'front-panel' } + if ($normalizedToken -match 'cosmetic') { return 'cosmetic' } + if ($normalizedToken -match 'window') { return 'window' } + if ($normalizedToken -match 'icon') { return 'icon' } + if ($normalizedToken -match 'documentation') { return 'documentation' } + if ($normalizedToken -match 'execution') { return 'execution' } return ($token -replace '[^a-z0-9]+', '-').Trim('-') } From 520068df6c41a1eeb0813d4cbf45f34e222d6902 Mon Sep 17 00:00:00 2001 From: Sergio Velderrain Date: Mon, 30 Mar 2026 18:44:53 -0700 Subject: [PATCH 05/16] Reveal collapsed VI history pairs (#2058) Co-authored-by: svelderrainruiz --- tests/CompareVI.History.Tests.ps1 | 16 ++++++++++--- tests/VICategoryBuckets.Tests.ps1 | 8 +++++++ tools/Compare-RefsToTemp.ps1 | 2 +- tools/Compare-VIHistory.ps1 | 11 ++++++--- tools/Publish-VICompareSummary.ps1 | 4 ++-- tools/Render-VIHistoryReport.ps1 | 38 +++++++++++++++++++++++++----- tools/VICategoryBuckets.psm1 | 1 + 7 files changed, 65 insertions(+), 15 deletions(-) diff --git a/tests/CompareVI.History.Tests.ps1 b/tests/CompareVI.History.Tests.ps1 index 2808afe73..dd6d854a5 100644 --- a/tests/CompareVI.History.Tests.ps1 +++ b/tests/CompareVI.History.Tests.ps1 @@ -502,6 +502,7 @@ exit 0 $modeManifest.stats.signalDiffs | Should -Be 0 $modeManifest.stats.noiseCollapsed | Should -BeGreaterThan 0 @($modeManifest.comparisons).Count | Should -Be 0 + @($modeManifest.collapsedComparisons).Count | Should -BeGreaterThan 0 $modeManifest.stats.collapsedNoise.count | Should -Be $modeManifest.stats.noiseCollapsed $modeManifest.stats.collapsedNoise.categoryCounts.PSObject.Properties.Name | Should -Contain 'vi-attribute' $modeManifest.stats.collapsedNoise.categoryCounts.PSObject.Properties.Name | Should -Not -Contain 'unspecified' @@ -512,12 +513,17 @@ exit 0 $aggregate.stats.signalDiffs | Should -Be 0 $aggregate.stats.noiseCollapsed | Should -BeGreaterThan 0 $aggregate.stats.categoryCounts.PSObject.Properties.Name | Should -Contain 'VI Attribute' + $aggregate.stats.categoryCounts.PSObject.Properties.Name | Should -Not -Contain 'Attributes' $aggregate.stats.categoryCounts.PSObject.Properties.Name | Should -Not -Contain 'Cosmetic' $aggregate.stats.categoryCounts.PSObject.Properties.Name | Should -Not -Contain 'unspecified' [int]$aggregate.stats.bucketCounts.metadata | Should -BeGreaterThan 0 $outputText | Should -Match 'LVCompare detected differences' $outputText | Should -Match 'VI attribute \(\d+\)' $outputText | Should -Not -Match 'unspecified' + + $historyMd = Get-Content -LiteralPath (Join-Path $rd 'history-report.md') -Raw + $historyMd | Should -Match 'collapsed noise' + $historyMd | Should -Match 'Touch history' } finally { if ($null -eq $previousDiff) { Remove-Item Env:STUB_COMPARE_DIFF -ErrorAction SilentlyContinue @@ -1449,7 +1455,10 @@ exit 0 $historyMd | Should -Match '\| Coverage Class \| `catalog-aligned` \|' $historyMd | Should -Match '## Mode overview' $historyMd | Should -Match '\| Mode \| Processed \| Diffs \| Signal \| Collapsed Noise \| Missing \| Categories \| Buckets \| Flags \|' - $historyMd | Should -Match '## Attribute coverage' + $historyMd | Should -Match '## Commit pairs' + $historyMd | Should -Match 'collapsed noise' + $historyMd | Should -Match 'Touch history' + $historyMd | Should -Match '## Mode filter coverage' $historyMd | Should -Match 'History manifest:' $historyHtml = Get-Content -LiteralPath (Join-Path $rd 'history-report.html') -Raw @@ -1463,8 +1472,9 @@ exit 0 $historyHtml | Should -Match 'Signal' $historyHtml | Should -Match 'Collapsed Noise' $historyHtml | Should -Match '

Commit pairs

' - $historyHtml | Should -Match 'No commit pairs were captured' - $historyHtml | Should -Match '

Attribute coverage

' + $historyHtml | Should -Match 'Collapsed noise' + $historyHtml | Should -Match 'Touch history' + $historyHtml | Should -Match '

Mode filter coverage

' } finally { if ($null -eq $previousDiff) { Remove-Item Env:STUB_COMPARE_DIFF -ErrorAction SilentlyContinue diff --git a/tests/VICategoryBuckets.Tests.ps1 b/tests/VICategoryBuckets.Tests.ps1 index ab7693b4f..51267a89b 100644 --- a/tests/VICategoryBuckets.Tests.ps1 +++ b/tests/VICategoryBuckets.Tests.ps1 @@ -14,6 +14,14 @@ Describe 'VICategoryBuckets module' -Tag 'Unit' { $meta.bucketClassification | Should -Be 'neutral' } + It 'treats generic Attributes as an alias of VI Attribute' { + $meta = Get-VICategoryMetadata -Name 'Attributes' + $meta | Should -Not -BeNullOrEmpty + $meta.slug | Should -Be 'vi-attribute' + $meta.label | Should -Be 'VI attribute' + $meta.bucketSlug | Should -Be 'metadata' + } + It 'preserves known slug inputs without degrading category specificity' { $meta = Get-VICategoryMetadata -Name 'block-diagram-cosmetic' $meta | Should -Not -BeNullOrEmpty diff --git a/tools/Compare-RefsToTemp.ps1 b/tools/Compare-RefsToTemp.ps1 index 09e6cd416..8fa85531c 100644 --- a/tools/Compare-RefsToTemp.ps1 +++ b/tools/Compare-RefsToTemp.ps1 @@ -324,7 +324,7 @@ function Normalize-ReportCategories { 'front-panel-position-size' { 'Front Panel Position/Size' } 'control-changes' { 'Front Panel Controls' } 'window' { 'Window Properties' } - 'attributes' { 'Attributes' } + 'attributes' { 'VI Attribute' } 'vi-attribute' { 'VI Attribute' } 'documentation' { 'Documentation' } 'execution' { 'Execution Settings' } diff --git a/tools/Compare-VIHistory.ps1 b/tools/Compare-VIHistory.ps1 index 2d6a7b6fa..694f9fe89 100644 --- a/tools/Compare-VIHistory.ps1 +++ b/tools/Compare-VIHistory.ps1 @@ -533,7 +533,7 @@ function Get-CanonicalComparisonCategoryName { 'front-panel-position-size' { return 'Front Panel Position/Size' } 'control-changes' { return 'Front Panel Controls' } 'window' { return 'Window Properties' } - 'attributes' { return 'Attributes' } + 'attributes' { return 'VI Attribute' } 'vi-attribute' { return 'VI Attribute' } 'documentation' { return 'Documentation' } 'execution' { return 'Execution Settings' } @@ -1837,6 +1837,7 @@ foreach ($modeSpec in $modeSpecs) { flags = $modeFlags resultsDir = $modeResultsResolved comparisons = @() + collapsedComparisons = @() stats = [ordered]@{ processed = 0 diffs = 0 @@ -2361,6 +2362,10 @@ foreach ($modeSpec in $modeSpecs) { } } + if ($collapsedThis) { + $modeManifest.collapsedComparisons += $comparisonRecordObject + } + if ($appendComparison) { $modeManifest.comparisons += $comparisonRecordObject } @@ -2836,7 +2841,7 @@ if (-not $renderSucceeded) { '| --- | --- | --- |' '| n/a | n/a | [report](./) |' '' - '## Attribute coverage' + '## Mode filter coverage' '' '_History renderer unavailable; see manifest for details._' ) @@ -2866,7 +2871,7 @@ if (-not $renderSucceeded) {
-

Attribute coverage

+

Mode filter coverage

History renderer unavailable.

diff --git a/tools/Publish-VICompareSummary.ps1 b/tools/Publish-VICompareSummary.ps1 index 56cd31d7e..1fb61cc9a 100644 --- a/tools/Publish-VICompareSummary.ps1 +++ b/tools/Publish-VICompareSummary.ps1 @@ -232,7 +232,7 @@ if ($totalDiffs -gt 0) { } $lines.Add("") -$lines.Add("#### Attribute coverage") +$lines.Add("#### Mode filter coverage") $attributeCoverageAdded = $false foreach ($mode in $modeSummaries) { $modeName = Get-ObjectPropertyValue -InputObject $mode -PropertyName 'mode' @@ -296,7 +296,7 @@ foreach ($mode in $modeSummaries) { } if (-not $attributeCoverageAdded) { - $lines.Add("- *(Attribute coverage unavailable in manifest)*") + $lines.Add("- *(Mode filter coverage unavailable in manifest)*") } $body = $lines -join "`n" diff --git a/tools/Render-VIHistoryReport.ps1 b/tools/Render-VIHistoryReport.ps1 index 143bf7a98..f8de616a3 100644 --- a/tools/Render-VIHistoryReport.ps1 +++ b/tools/Render-VIHistoryReport.ps1 @@ -823,7 +823,19 @@ function Build-FallbackHistoryContext { continue } - foreach ($comparison in @($modeManifest.comparisons)) { + $modeComparisons = New-Object System.Collections.Generic.List[object] + foreach ($comparisonEntry in @($modeManifest.comparisons)) { + if ($comparisonEntry) { + $modeComparisons.Add($comparisonEntry) | Out-Null + } + } + foreach ($comparisonEntry in @($modeManifest.collapsedComparisons)) { + if ($comparisonEntry) { + $modeComparisons.Add($comparisonEntry) | Out-Null + } + } + + foreach ($comparison in @($modeComparisons | Sort-Object { [int](Coalesce $_.index 0) })) { if (-not $comparison) { continue } $baseNode = $comparison.base $headNode = $comparison.head @@ -864,6 +876,9 @@ function Build-FallbackHistoryContext { if ($resultNode.PSObject.Properties['classification'] -and $resultNode.classification) { $resultPayload.classification = $resultNode.classification } + if ($resultNode.PSObject.Properties['collapsed']) { + $resultPayload.collapsed = [bool]$resultNode.collapsed + } if ($resultNode.PSObject.Properties['artifactDir'] -and $resultNode.artifactDir) { $resultPayload.artifactDir = $resultNode.artifactDir } @@ -1217,7 +1232,13 @@ if ($comparisons.Count -gt 0) { $diffValue = $hasDiffValue -and ($resultNode.diff -eq $true) $statusValue = if ($resultNode -and $resultNode.PSObject.Properties['status']) { [string]$resultNode.status } else { $null } $diffCell = if ($hasDiffValue) { - if ($diffValue) { '**diff**' } else { 'clean' } + if ($diffValue) { + if ($resultNode -and $resultNode.PSObject.Properties['collapsed'] -and [bool]$resultNode.collapsed) { + '_collapsed noise_' + } else { + '**diff**' + } + } else { 'clean' } } elseif ($statusValue) { ('_{0}_' -f $statusValue) } else { @@ -1310,6 +1331,7 @@ if ($comparisons.Count -gt 0) { LineageLabel = $lineageLabel LineageType = if ($lineageNode -and $lineageNode.PSObject.Properties['type']) { [string]$lineageNode.type } else { 'mainline' } Diff = [bool]$diffValue + Collapsed = if ($resultNode -and $resultNode.PSObject.Properties['collapsed']) { [bool]$resultNode.collapsed } else { $false } HasDiff = $hasDiffValue Status = $statusValue Duration = $durationValue @@ -1336,7 +1358,7 @@ if ($comparisons.Count -gt 0) { } $summaryLines.Add('') -$summaryLines.Add('## Attribute coverage') +$summaryLines.Add('## Mode filter coverage') $summaryLines.Add('') if ($modeEntries.Count -gt 0) { foreach ($mode in $modeEntries) { @@ -1518,8 +1540,12 @@ if ($emitHtml -and $HtmlPath) { [void]$htmlBuilder.AppendLine(' ModePairLineageBaseHeadDiffDuration (s)CategoriesBucketsReportHighlights') [void]$htmlBuilder.AppendLine(' ') foreach ($row in $comparisonHtmlRows) { - $diffClass = if ($row.Diff) { 'diff-yes' } elseif ($row.Status) { 'diff-status' } else { 'diff-no' } - $diffLabel = if ($row.Diff) { 'Diff' } elseif ($row.Status) { ConvertTo-HtmlSafe $row.Status } else { 'No' } + $diffClass = if ($row.Diff) { + if ($row.Collapsed) { 'diff-collapsed' } else { 'diff-yes' } + } elseif ($row.Status) { 'diff-status' } else { 'diff-no' } + $diffLabel = if ($row.Diff) { + if ($row.Collapsed) { 'Collapsed noise' } else { 'Diff' } + } elseif ($row.Status) { ConvertTo-HtmlSafe $row.Status } else { 'No' } $durationDisplay = 'n/a' if ($row.DurationDisplay -and $row.DurationDisplay -ne 'n/a') { $durationDisplay = ConvertTo-HtmlSafe $row.DurationDisplay @@ -1688,7 +1714,7 @@ if ($emitHtml -and $HtmlPath) { [void]$htmlBuilder.AppendLine('

No commit pairs were captured for the requested history window.

') } - [void]$htmlBuilder.AppendLine('

Attribute coverage

') + [void]$htmlBuilder.AppendLine('

Mode filter coverage

') if ($modeEntries.Count -gt 0) { [void]$htmlBuilder.AppendLine('
    ') foreach ($mode in $modeEntries) { diff --git a/tools/VICategoryBuckets.psm1 b/tools/VICategoryBuckets.psm1 index 9284566c2..a13d8dbff 100644 --- a/tools/VICategoryBuckets.psm1 +++ b/tools/VICategoryBuckets.psm1 @@ -105,6 +105,7 @@ function Resolve-VICategorySlug { if ([string]::IsNullOrWhiteSpace($Name)) { return $null } $token = $Name.Trim().ToLowerInvariant() + if ($token -eq 'attributes') { return 'vi-attribute' } if ($script:CategoryDefinitions.ContainsKey($token)) { return $token } $normalizedToken = $token -replace '[-_]+', ' ' From e17aa99160686b294a4264601e0bdb926d55f7e5 Mon Sep 17 00:00:00 2001 From: Sergio Velderrain Date: Mon, 30 Mar 2026 19:16:04 -0700 Subject: [PATCH 06/16] Clarify VI history decision guidance (#2059) * Add VI history decision guidance * Prioritize VI history signal buckets * Separate VI history focus from context --------- Co-authored-by: svelderrainruiz --- ...parevi-tools-history-facade-v1.schema.json | 61 +++++++ tests/CompareVI.History.Tests.ps1 | 12 ++ tools/Render-VIHistoryReport.ps1 | 161 +++++++++++++++++- 3 files changed, 233 insertions(+), 1 deletion(-) diff --git a/docs/schemas/comparevi-tools-history-facade-v1.schema.json b/docs/schemas/comparevi-tools-history-facade-v1.schema.json index 0bd39b4ea..249ecd86d 100644 --- a/docs/schemas/comparevi-tools-history-facade-v1.schema.json +++ b/docs/schemas/comparevi-tools-history-facade-v1.schema.json @@ -10,6 +10,7 @@ "execution", "observedInterpretation", "summary", + "decisionGuidance", "reports", "modes" ], @@ -149,6 +150,66 @@ }, "additionalProperties": false }, + "decisionGuidance": { + "type": "object", + "required": [ + "reviewPriority", + "latestPair", + "signalPairs", + "collapsedPairs", + "focusBuckets", + "contextBuckets" + ], + "properties": { + "reviewPriority": { + "type": "string" + }, + "latestPair": { + "type": "object", + "required": [ + "index", + "status" + ], + "properties": { + "index": { + "type": "integer", + "minimum": 0 + }, + "status": { + "type": "string" + } + }, + "additionalProperties": false + }, + "signalPairs": { + "type": "array", + "items": { + "type": "integer", + "minimum": 0 + } + }, + "collapsedPairs": { + "type": "array", + "items": { + "type": "integer", + "minimum": 0 + } + }, + "focusBuckets": { + "type": "array", + "items": { + "type": "string" + } + }, + "contextBuckets": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, "summary": { "type": "object", "required": [ diff --git a/tests/CompareVI.History.Tests.ps1 b/tests/CompareVI.History.Tests.ps1 index dd6d854a5..6d7e6946c 100644 --- a/tests/CompareVI.History.Tests.ps1 +++ b/tests/CompareVI.History.Tests.ps1 @@ -1445,6 +1445,12 @@ exit 0 $historySummary.observedInterpretation.coverageClass | Should -Be 'catalog-aligned' @($historySummary.execution.requestedModes) | Should -Be @('default') @($historySummary.execution.executedModes) | Should -Be @('default') + $historySummary.decisionGuidance.reviewPriority | Should -Be 'metadata-only-history' + @($historySummary.decisionGuidance.signalPairs) | Should -Be @() + @($historySummary.decisionGuidance.collapsedPairs) | Should -Be @(1) + $historySummary.decisionGuidance.latestPair.index | Should -Be 1 + $historySummary.decisionGuidance.latestPair.status | Should -Be 'collapsed-noise' + @($historySummary.decisionGuidance.PSObject.Properties.Name) | Should -Contain 'contextBuckets' $historyMd = Get-Content -LiteralPath (Join-Path $rd 'history-report.md') -Raw $historyMd | Should -Match 'Requested Modes: `default`' @@ -1455,6 +1461,9 @@ exit 0 $historyMd | Should -Match '\| Coverage Class \| `catalog-aligned` \|' $historyMd | Should -Match '## Mode overview' $historyMd | Should -Match '\| Mode \| Processed \| Diffs \| Signal \| Collapsed Noise \| Missing \| Categories \| Buckets \| Flags \|' + $historyMd | Should -Match '## Decision guidance' + $historyMd | Should -Match 'Review priority' + $historyMd | Should -Match 'Latest pair' $historyMd | Should -Match '## Commit pairs' $historyMd | Should -Match 'collapsed noise' $historyMd | Should -Match 'Touch history' @@ -1471,6 +1480,9 @@ exit 0 $historyHtml | Should -Match '

    Summary

    ' $historyHtml | Should -Match 'Signal' $historyHtml | Should -Match 'Collapsed Noise' + $historyHtml | Should -Match '

    Decision guidance

    ' + $historyHtml | Should -Match 'Review priority' + $historyHtml | Should -Match 'Latest pair' $historyHtml | Should -Match '

    Commit pairs

    ' $historyHtml | Should -Match 'Collapsed noise' $historyHtml | Should -Match 'Touch history' diff --git a/tools/Render-VIHistoryReport.ps1 b/tools/Render-VIHistoryReport.ps1 index f8de616a3..e1a7bc06d 100644 --- a/tools/Render-VIHistoryReport.ps1 +++ b/tools/Render-VIHistoryReport.ps1 @@ -664,12 +664,16 @@ function Get-BucketLabelEntries { foreach ($item in $items) { if ($null -eq $item) { continue } $slug = $null + $classification = $null if ($item -is [pscustomobject]) { if ($item.PSObject.Properties['bucketSlug']) { $slug = [string]$item.bucketSlug } elseif ($item.PSObject.Properties['slug']) { $slug = [string]$item.slug } + if ($item.PSObject.Properties['classification']) { + $classification = [string]$item.classification + } } else { $slug = [string]$item } @@ -680,7 +684,7 @@ function Get-BucketLabelEntries { $entries.Add([pscustomobject]@{ slug = $meta.slug label = $meta.label - classification = $meta.classification + classification = if ([string]::IsNullOrWhiteSpace($classification)) { $meta.classification } else { $classification } }) | Out-Null } } @@ -1203,6 +1207,126 @@ if ($modeEntries.Count -gt 0) { $stepSummaryLines = @($summaryLines) +$sortedComparisons = @( + $comparisons | + Sort-Object { + try { [int](Coalesce $_.index 0) } catch { 0 } + } +) +$latestComparisonEntry = if ($sortedComparisons.Count -gt 0) { $sortedComparisons[0] } else { $null } +$collapsedComparisonEntries = @( + $sortedComparisons | + Where-Object { + $_.result -and + $_.result.PSObject.Properties['collapsed'] -and + [bool]$_.result.collapsed + } +) +$signalComparisonEntries = @( + $sortedComparisons | + Where-Object { + $resultNode = $_.result + if (-not $resultNode) { return $false } + $hasDiff = $resultNode.PSObject.Properties['diff'] -and ($resultNode.diff -eq $true) + if (-not $hasDiff) { return $false } + if ($resultNode.PSObject.Properties['collapsed'] -and [bool]$resultNode.collapsed) { return $false } + $detailNodes = @() + if ($resultNode.PSObject.Properties['categoryDetails'] -and $resultNode.categoryDetails) { + $detailNodes = @($resultNode.categoryDetails) + } + if ($detailNodes.Count -eq 0) { return $true } + @($detailNodes | Where-Object { [string]$_.classification -ne 'noise' }).Count -gt 0 + } +) +$decisionFocusBuckets = New-Object System.Collections.Generic.List[string] +$decisionFocusBucketSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) +$decisionContextBuckets = New-Object System.Collections.Generic.List[string] +$decisionContextBucketSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) +foreach ($decisionEntry in $signalComparisonEntries) { + $decisionResultNode = $decisionEntry.result + if (-not $decisionResultNode) { continue } + $decisionBucketEntries = @() + if ($decisionResultNode.PSObject.Properties['categoryBucketDetails'] -and $decisionResultNode.categoryBucketDetails) { + $decisionBucketEntries = @(Get-BucketLabelEntries -Buckets $decisionResultNode.categoryBucketDetails) + } elseif ($decisionResultNode.PSObject.Properties['categoryBuckets'] -and $decisionResultNode.categoryBuckets) { + $decisionBucketEntries = @(Get-BucketLabelEntries -Buckets $decisionResultNode.categoryBuckets) + } + foreach ($decisionBucketEntry in $decisionBucketEntries) { + if ($null -eq $decisionBucketEntry) { continue } + $bucketLabel = [string]$decisionBucketEntry.label + if ([string]::IsNullOrWhiteSpace($bucketLabel)) { continue } + $bucketDisplayLabel = $bucketLabel + $isContextBucket = $false + switch ([string]$decisionBucketEntry.classification) { + 'noise' { + $bucketDisplayLabel = '{0} (noise)' -f $bucketLabel + if ($decisionContextBucketSet.Add($bucketDisplayLabel)) { + $decisionContextBuckets.Add($bucketDisplayLabel) | Out-Null + } + $isContextBucket = $true + break + } + 'neutral' { + $bucketDisplayLabel = '{0} (neutral)' -f $bucketLabel + if ($decisionContextBucketSet.Add($bucketDisplayLabel)) { + $decisionContextBuckets.Add($bucketDisplayLabel) | Out-Null + } + $isContextBucket = $true + break + } + } + if ($isContextBucket) { continue } + if ($decisionFocusBucketSet.Add($bucketDisplayLabel)) { + $decisionFocusBuckets.Add($bucketDisplayLabel) | Out-Null + } + } +} +$decisionReviewPriority = if ($signalComparisonEntries.Count -gt 0) { + 'review-signal-pairs' +} elseif ($collapsedComparisonEntries.Count -gt 0) { + 'metadata-only-history' +} else { + 'no-diff' +} +$decisionLatestStatus = 'n/a' +if ($latestComparisonEntry) { + $latestResultNode = $latestComparisonEntry.result + if ($latestResultNode -and $latestResultNode.PSObject.Properties['diff'] -and ($latestResultNode.diff -eq $true)) { + if ($latestResultNode.PSObject.Properties['collapsed'] -and [bool]$latestResultNode.collapsed) { + $decisionLatestStatus = 'collapsed-noise' + } else { + $decisionLatestStatus = 'signal-review' + } + } elseif ($latestResultNode -and $latestResultNode.PSObject.Properties['status'] -and $latestResultNode.status) { + $decisionLatestStatus = [string]$latestResultNode.status + } else { + $decisionLatestStatus = 'clean' + } +} +if ($sortedComparisons.Count -gt 0) { + $summaryLines.Add('') + $summaryLines.Add('## Decision guidance') + $summaryLines.Add('') + $summaryLines.Add(('- Review priority: `{0}`' -f $decisionReviewPriority)) + if ($latestComparisonEntry) { + $summaryLines.Add(('- Latest pair: `pair {0}` is `{1}`' -f (Coalesce $latestComparisonEntry.index 'n/a'), $decisionLatestStatus)) + } + if ($signalComparisonEntries.Count -gt 0) { + $summaryLines.Add(('- Signal pairs: `{0}`' -f ([string]::Join(', ', @($signalComparisonEntries | ForEach-Object { [string](Coalesce $_.index 'n/a') }))))) + } else { + $summaryLines.Add('- Signal pairs: `none`') + } + if ($collapsedComparisonEntries.Count -gt 0) { + $summaryLines.Add(('- Collapsed pairs: `{0}`' -f ([string]::Join(', ', @($collapsedComparisonEntries | ForEach-Object { [string](Coalesce $_.index 'n/a') }))))) + } + if ($decisionFocusBuckets.Count -gt 0) { + $summaryLines.Add(('- Focus buckets: `{0}`' -f ([string]::Join(', ', @($decisionFocusBuckets.ToArray()))))) + } + if ($decisionContextBuckets.Count -gt 0) { + $summaryLines.Add(('- Context buckets: `{0}`' -f ([string]::Join(', ', @($decisionContextBuckets.ToArray()))))) + } +} + $comparisonHtmlRows = New-Object System.Collections.Generic.List[object] if ($comparisons.Count -gt 0) { $summaryLines.Add('') @@ -1534,6 +1658,30 @@ if ($emitHtml -and $HtmlPath) { [void]$htmlBuilder.AppendLine(' ') } + if ($sortedComparisons.Count -gt 0) { + [void]$htmlBuilder.AppendLine('

    Decision guidance

    ') + [void]$htmlBuilder.AppendLine('
      ') + [void]$htmlBuilder.AppendLine(('
    • Review priority{0}
    • ' -f (Format-HtmlCodeList -Values @($decisionReviewPriority)))) + if ($latestComparisonEntry) { + [void]$htmlBuilder.AppendLine(('
    • Latest pairpair {0} is {1}
    • ' -f (ConvertTo-HtmlSafe (Coalesce $latestComparisonEntry.index 'n/a')), (ConvertTo-HtmlSafe $decisionLatestStatus))) + } + if ($signalComparisonEntries.Count -gt 0) { + [void]$htmlBuilder.AppendLine(('
    • Signal pairs{0}
    • ' -f (ConvertTo-HtmlSafe ([string]::Join(', ', @($signalComparisonEntries | ForEach-Object { [string](Coalesce $_.index 'n/a') })))))) + } else { + [void]$htmlBuilder.AppendLine('
    • Signal pairsnone
    • ') + } + if ($collapsedComparisonEntries.Count -gt 0) { + [void]$htmlBuilder.AppendLine(('
    • Collapsed pairs{0}
    • ' -f (ConvertTo-HtmlSafe ([string]::Join(', ', @($collapsedComparisonEntries | ForEach-Object { [string](Coalesce $_.index 'n/a') })))))) + } + if ($decisionFocusBuckets.Count -gt 0) { + [void]$htmlBuilder.AppendLine(('
    • Focus buckets{0}
    • ' -f (ConvertTo-HtmlSafe ([string]::Join(', ', @($decisionFocusBuckets.ToArray())))))) + } + if ($decisionContextBuckets.Count -gt 0) { + [void]$htmlBuilder.AppendLine(('
    • Context buckets{0}
    • ' -f (ConvertTo-HtmlSafe ([string]::Join(', ', @($decisionContextBuckets.ToArray())))))) + } + [void]$htmlBuilder.AppendLine('
    ') + } + [void]$htmlBuilder.AppendLine('

    Commit pairs

    ') if ($comparisonHtmlRows.Count -gt 0) { [void]$htmlBuilder.AppendLine(' ') @@ -1810,6 +1958,17 @@ $historySummary = [ordered]@{ modeSensitivity = [string]$modeSensitivity outcomeLabels = @(Get-SortedUniqueStringArray -Value $outcomeLabels) } + decisionGuidance = [ordered]@{ + reviewPriority = [string]$decisionReviewPriority + latestPair = [ordered]@{ + index = if ($latestComparisonEntry) { [int](Coalesce $latestComparisonEntry.index 0) } else { 0 } + status = [string]$decisionLatestStatus + } + signalPairs = @($signalComparisonEntries | ForEach-Object { [int](Coalesce $_.index 0) }) + collapsedPairs = @($collapsedComparisonEntries | ForEach-Object { [int](Coalesce $_.index 0) }) + focusBuckets = @($decisionFocusBuckets.ToArray()) + contextBuckets = @($decisionContextBuckets.ToArray()) + } summary = [ordered]@{ modes = if ($stats -and $stats.PSObject.Properties['modes']) { [int]$stats.modes } else { [int]$modeEntries.Count } comparisons = Get-IntPropertyValue -InputObject $stats -Name 'processed' From 6bce62f4e07f4f2b43401fd1396377d86c143b7a Mon Sep 17 00:00:00 2001 From: Sergio Velderrain Date: Mon, 30 Mar 2026 19:18:17 -0700 Subject: [PATCH 07/16] Make LV32 shadow proof non-blocking (#2060) Co-authored-by: svelderrainruiz --- .github/workflows/validate.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 61a2efc24..5f784c99b 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -1825,6 +1825,7 @@ jobs: needs: [smoke-gate, lint, session-index, session-index-v2-contract, vi-history-scenarios-plan, vi-history-scenarios-windows-lv32-plan] if: needs.smoke-gate.outputs.skip != 'true' && needs.vi-history-scenarios-plan.outputs.execute_lanes == 'true' && needs.vi-history-scenarios-windows-lv32-plan.outputs.available == 'true' runs-on: [self-hosted, Windows, X64, comparevi, capability-ingress, labview-2026, lv32] + continue-on-error: true timeout-minutes: 60 permissions: actions: read From a85d77af72049056ea3f4c0ffbfb17f16986cd6b Mon Sep 17 00:00:00 2001 From: Sergio Velderrain Date: Mon, 30 Mar 2026 19:30:35 -0700 Subject: [PATCH 08/16] Identify latest VI history signal pair (#2061) Co-authored-by: svelderrainruiz --- ...parevi-tools-history-facade-v1.schema.json | 22 +++++++++++++++++++ tests/CompareVI.History.Tests.ps1 | 3 +++ tools/Render-VIHistoryReport.ps1 | 16 ++++++++++++++ 3 files changed, 41 insertions(+) diff --git a/docs/schemas/comparevi-tools-history-facade-v1.schema.json b/docs/schemas/comparevi-tools-history-facade-v1.schema.json index 249ecd86d..3ad2c207d 100644 --- a/docs/schemas/comparevi-tools-history-facade-v1.schema.json +++ b/docs/schemas/comparevi-tools-history-facade-v1.schema.json @@ -155,6 +155,7 @@ "required": [ "reviewPriority", "latestPair", + "latestSignalPair", "signalPairs", "collapsedPairs", "focusBuckets", @@ -181,6 +182,27 @@ }, "additionalProperties": false }, + "latestSignalPair": { + "type": "object", + "required": [ + "index", + "baseRef", + "headRef" + ], + "properties": { + "index": { + "type": "integer", + "minimum": 0 + }, + "baseRef": { + "type": "string" + }, + "headRef": { + "type": "string" + } + }, + "additionalProperties": false + }, "signalPairs": { "type": "array", "items": { diff --git a/tests/CompareVI.History.Tests.ps1 b/tests/CompareVI.History.Tests.ps1 index 6d7e6946c..ae008ec4d 100644 --- a/tests/CompareVI.History.Tests.ps1 +++ b/tests/CompareVI.History.Tests.ps1 @@ -1450,6 +1450,7 @@ exit 0 @($historySummary.decisionGuidance.collapsedPairs) | Should -Be @(1) $historySummary.decisionGuidance.latestPair.index | Should -Be 1 $historySummary.decisionGuidance.latestPair.status | Should -Be 'collapsed-noise' + $historySummary.decisionGuidance.latestSignalPair.index | Should -Be 0 @($historySummary.decisionGuidance.PSObject.Properties.Name) | Should -Contain 'contextBuckets' $historyMd = Get-Content -LiteralPath (Join-Path $rd 'history-report.md') -Raw @@ -1464,6 +1465,7 @@ exit 0 $historyMd | Should -Match '## Decision guidance' $historyMd | Should -Match 'Review priority' $historyMd | Should -Match 'Latest pair' + $historyMd | Should -Not -Match 'Review first' $historyMd | Should -Match '## Commit pairs' $historyMd | Should -Match 'collapsed noise' $historyMd | Should -Match 'Touch history' @@ -1483,6 +1485,7 @@ exit 0 $historyHtml | Should -Match '

    Decision guidance

    ' $historyHtml | Should -Match 'Review priority' $historyHtml | Should -Match 'Latest pair' + $historyHtml | Should -Not -Match 'Review first' $historyHtml | Should -Match '

    Commit pairs

    ' $historyHtml | Should -Match 'Collapsed noise' $historyHtml | Should -Match 'Touch history' diff --git a/tools/Render-VIHistoryReport.ps1 b/tools/Render-VIHistoryReport.ps1 index e1a7bc06d..059c02dcb 100644 --- a/tools/Render-VIHistoryReport.ps1 +++ b/tools/Render-VIHistoryReport.ps1 @@ -1288,6 +1288,7 @@ $decisionReviewPriority = if ($signalComparisonEntries.Count -gt 0) { } else { 'no-diff' } +$latestSignalComparisonEntry = if ($signalComparisonEntries.Count -gt 0) { $signalComparisonEntries[0] } else { $null } $decisionLatestStatus = 'n/a' if ($latestComparisonEntry) { $latestResultNode = $latestComparisonEntry.result @@ -1311,6 +1312,11 @@ if ($sortedComparisons.Count -gt 0) { if ($latestComparisonEntry) { $summaryLines.Add(('- Latest pair: `pair {0}` is `{1}`' -f (Coalesce $latestComparisonEntry.index 'n/a'), $decisionLatestStatus)) } + if ($latestSignalComparisonEntry) { + $latestSignalBaseRef = [string](Coalesce (Coalesce $latestSignalComparisonEntry.base.short $latestSignalComparisonEntry.base.full) 'n/a') + $latestSignalHeadRef = [string](Coalesce (Coalesce $latestSignalComparisonEntry.head.short $latestSignalComparisonEntry.head.full) 'n/a') + $summaryLines.Add(('- Review first: `pair {0}` (`{1}` -> `{2}`)' -f (Coalesce $latestSignalComparisonEntry.index 'n/a'), $latestSignalBaseRef, $latestSignalHeadRef)) + } if ($signalComparisonEntries.Count -gt 0) { $summaryLines.Add(('- Signal pairs: `{0}`' -f ([string]::Join(', ', @($signalComparisonEntries | ForEach-Object { [string](Coalesce $_.index 'n/a') }))))) } else { @@ -1665,6 +1671,11 @@ if ($emitHtml -and $HtmlPath) { if ($latestComparisonEntry) { [void]$htmlBuilder.AppendLine(('
  • Latest pairpair {0} is {1}
  • ' -f (ConvertTo-HtmlSafe (Coalesce $latestComparisonEntry.index 'n/a')), (ConvertTo-HtmlSafe $decisionLatestStatus))) } + if ($latestSignalComparisonEntry) { + $latestSignalBaseRef = [string](Coalesce (Coalesce $latestSignalComparisonEntry.base.short $latestSignalComparisonEntry.base.full) 'n/a') + $latestSignalHeadRef = [string](Coalesce (Coalesce $latestSignalComparisonEntry.head.short $latestSignalComparisonEntry.head.full) 'n/a') + [void]$htmlBuilder.AppendLine(('
  • Review firstpair {0} ({1} -> {2})
  • ' -f (ConvertTo-HtmlSafe (Coalesce $latestSignalComparisonEntry.index 'n/a')), (ConvertTo-HtmlSafe $latestSignalBaseRef), (ConvertTo-HtmlSafe $latestSignalHeadRef))) + } if ($signalComparisonEntries.Count -gt 0) { [void]$htmlBuilder.AppendLine(('
  • Signal pairs{0}
  • ' -f (ConvertTo-HtmlSafe ([string]::Join(', ', @($signalComparisonEntries | ForEach-Object { [string](Coalesce $_.index 'n/a') })))))) } else { @@ -1964,6 +1975,11 @@ $historySummary = [ordered]@{ index = if ($latestComparisonEntry) { [int](Coalesce $latestComparisonEntry.index 0) } else { 0 } status = [string]$decisionLatestStatus } + latestSignalPair = [ordered]@{ + index = if ($latestSignalComparisonEntry) { [int](Coalesce $latestSignalComparisonEntry.index 0) } else { 0 } + baseRef = if ($latestSignalComparisonEntry) { [string](Coalesce (Coalesce $latestSignalComparisonEntry.base.short $latestSignalComparisonEntry.base.full) '') } else { '' } + headRef = if ($latestSignalComparisonEntry) { [string](Coalesce (Coalesce $latestSignalComparisonEntry.head.short $latestSignalComparisonEntry.head.full) '') } else { '' } + } signalPairs = @($signalComparisonEntries | ForEach-Object { [int](Coalesce $_.index 0) }) collapsedPairs = @($collapsedComparisonEntries | ForEach-Object { [int](Coalesce $_.index 0) }) focusBuckets = @($decisionFocusBuckets.ToArray()) From 6a91ca561a544e94d6cf0ea54e10a5698795f1fb Mon Sep 17 00:00:00 2001 From: Sergio Velderrain Date: Mon, 30 Mar 2026 19:43:43 -0700 Subject: [PATCH 09/16] Add VI history review sequence guidance (#2062) Co-authored-by: svelderrainruiz --- ...parevi-tools-history-facade-v1.schema.json | 42 ++++++++++++++++++- tests/Render-VIHistoryReport.Tests.ps1 | 14 +++++++ tools/Render-VIHistoryReport.ps1 | 42 ++++++++++++++++++- 3 files changed, 96 insertions(+), 2 deletions(-) diff --git a/docs/schemas/comparevi-tools-history-facade-v1.schema.json b/docs/schemas/comparevi-tools-history-facade-v1.schema.json index 3ad2c207d..817c66f81 100644 --- a/docs/schemas/comparevi-tools-history-facade-v1.schema.json +++ b/docs/schemas/comparevi-tools-history-facade-v1.schema.json @@ -156,6 +156,7 @@ "reviewPriority", "latestPair", "latestSignalPair", + "reviewSequence", "signalPairs", "collapsedPairs", "focusBuckets", @@ -187,7 +188,9 @@ "required": [ "index", "baseRef", - "headRef" + "headRef", + "baseSubject", + "headSubject" ], "properties": { "index": { @@ -199,10 +202,47 @@ }, "headRef": { "type": "string" + }, + "baseSubject": { + "type": "string" + }, + "headSubject": { + "type": "string" } }, "additionalProperties": false }, + "reviewSequence": { + "type": "array", + "items": { + "type": "object", + "required": [ + "index", + "baseRef", + "headRef", + "baseSubject", + "headSubject" + ], + "properties": { + "index": { + "type": "integer", + "minimum": 0 + }, + "baseRef": { + "type": "string" + }, + "headRef": { + "type": "string" + }, + "baseSubject": { + "type": "string" + }, + "headSubject": { + "type": "string" + } + } + } + }, "signalPairs": { "type": "array", "items": { diff --git a/tests/Render-VIHistoryReport.Tests.ps1 b/tests/Render-VIHistoryReport.Tests.ps1 index d1273d82a..7ceecbc0b 100644 --- a/tests/Render-VIHistoryReport.Tests.ps1 +++ b/tests/Render-VIHistoryReport.Tests.ps1 @@ -216,6 +216,10 @@ Describe 'Render-VIHistoryReport.ps1' -Tag 'Unit' { $markdown | Should -Match '\| Coverage Class \| `catalog-partial` \|' $markdown | Should -Match '\| Mode Sensitivity \| `single-mode-observed` \|' $markdown | Should -Match '\| Outcome Labels \| `clean`, `signal-diff` \|' + $markdown | Should -Match '## Decision guidance' + $markdown | Should -Match 'Review first' + $markdown | Should -Match 'Review sequence' + $markdown | Should -Match 'pair 2 \(Base commit -> Head commit\)' $markdown | Should -Match '\| Mode \| Processed \| Diffs \| Signal \| Collapsed Noise \| Missing \| Categories \| Buckets \| Flags \|' $markdown | Should -Match '\| Mode \| Pair \| Lineage \| Base \| Head \| Diff \| Duration \(s\) \| Categories \| Buckets \| Report \| Highlights \|' $markdown | Should -Match 'Touch history' @@ -234,6 +238,9 @@ Describe 'Render-VIHistoryReport.ps1' -Tag 'Unit' { $html | Should -Match 'single-mode-observed' $html | Should -Match 'Outcome Labels' $html | Should -Match 'clean, signal-diff' + $html | Should -Match 'Review first' + $html | Should -Match 'Review sequence' + $html | Should -Match 'pair 2 \(Base commit -> Head commit\)' $html | Should -Match '
    ' $html | Should -Match '' $html | Should -Match '' @@ -264,6 +271,13 @@ Describe 'Render-VIHistoryReport.ps1' -Tag 'Unit' { $historySummary.target.sourceBranchRef | Should -Be 'feature/history-source' $historySummary.target.branchBudget.maxCommitCount | Should -Be 64 $historySummary.target.branchBudget.commitCount | Should -Be 3 + $historySummary.decisionGuidance.latestSignalPair.index | Should -Be 2 + $historySummary.decisionGuidance.latestSignalPair.baseSubject | Should -Be 'Base commit' + $historySummary.decisionGuidance.latestSignalPair.headSubject | Should -Be 'Head commit' + @($historySummary.decisionGuidance.reviewSequence).Count | Should -Be 1 + $historySummary.decisionGuidance.reviewSequence[0].index | Should -Be 2 + $historySummary.decisionGuidance.reviewSequence[0].baseSubject | Should -Be 'Base commit' + $historySummary.decisionGuidance.reviewSequence[0].headSubject | Should -Be 'Head commit' } It 'preserves branch budget numeric fields when the source object is a hashtable' { diff --git a/tools/Render-VIHistoryReport.ps1 b/tools/Render-VIHistoryReport.ps1 index 059c02dcb..719a3f5bd 100644 --- a/tools/Render-VIHistoryReport.ps1 +++ b/tools/Render-VIHistoryReport.ps1 @@ -1288,6 +1288,17 @@ $decisionReviewPriority = if ($signalComparisonEntries.Count -gt 0) { } else { 'no-diff' } +$decisionReviewSequence = @( + $signalComparisonEntries | ForEach-Object { + [ordered]@{ + index = [int](Coalesce $_.index 0) + baseRef = [string](Coalesce (Coalesce $_.base.short $_.base.full) '') + headRef = [string](Coalesce (Coalesce $_.head.short $_.head.full) '') + baseSubject = [string](Coalesce $_.base.subject '') + headSubject = [string](Coalesce $_.head.subject '') + } + } +) $latestSignalComparisonEntry = if ($signalComparisonEntries.Count -gt 0) { $signalComparisonEntries[0] } else { $null } $decisionLatestStatus = 'n/a' if ($latestComparisonEntry) { @@ -1317,6 +1328,14 @@ if ($sortedComparisons.Count -gt 0) { $latestSignalHeadRef = [string](Coalesce (Coalesce $latestSignalComparisonEntry.head.short $latestSignalComparisonEntry.head.full) 'n/a') $summaryLines.Add(('- Review first: `pair {0}` (`{1}` -> `{2}`)' -f (Coalesce $latestSignalComparisonEntry.index 'n/a'), $latestSignalBaseRef, $latestSignalHeadRef)) } + if ($decisionReviewSequence.Count -gt 0) { + $reviewSequenceLabels = @( + $decisionReviewSequence | ForEach-Object { + 'pair {0} ({1} -> {2})' -f $_.index, (Coalesce $_.baseSubject 'n/a'), (Coalesce $_.headSubject 'n/a') + } + ) + $summaryLines.Add(('- Review sequence: `{0}`' -f ([string]::Join('; ', $reviewSequenceLabels)))) + } if ($signalComparisonEntries.Count -gt 0) { $summaryLines.Add(('- Signal pairs: `{0}`' -f ([string]::Join(', ', @($signalComparisonEntries | ForEach-Object { [string](Coalesce $_.index 'n/a') }))))) } else { @@ -1676,6 +1695,14 @@ if ($emitHtml -and $HtmlPath) { $latestSignalHeadRef = [string](Coalesce (Coalesce $latestSignalComparisonEntry.head.short $latestSignalComparisonEntry.head.full) 'n/a') [void]$htmlBuilder.AppendLine(('
  • Review firstpair {0} ({1} -> {2})
  • ' -f (ConvertTo-HtmlSafe (Coalesce $latestSignalComparisonEntry.index 'n/a')), (ConvertTo-HtmlSafe $latestSignalBaseRef), (ConvertTo-HtmlSafe $latestSignalHeadRef))) } + if ($decisionReviewSequence.Count -gt 0) { + $reviewSequenceLabels = @( + $decisionReviewSequence | ForEach-Object { + 'pair {0} ({1} -> {2})' -f $_.index, (Coalesce $_.baseSubject 'n/a'), (Coalesce $_.headSubject 'n/a') + } + ) + [void]$htmlBuilder.AppendLine(('
  • Review sequence{0}
  • ' -f (ConvertTo-HtmlSafe ([string]::Join('; ', $reviewSequenceLabels))))) + } if ($signalComparisonEntries.Count -gt 0) { [void]$htmlBuilder.AppendLine(('
  • Signal pairs{0}
  • ' -f (ConvertTo-HtmlSafe ([string]::Join(', ', @($signalComparisonEntries | ForEach-Object { [string](Coalesce $_.index 'n/a') })))))) } else { @@ -1979,7 +2006,20 @@ $historySummary = [ordered]@{ index = if ($latestSignalComparisonEntry) { [int](Coalesce $latestSignalComparisonEntry.index 0) } else { 0 } baseRef = if ($latestSignalComparisonEntry) { [string](Coalesce (Coalesce $latestSignalComparisonEntry.base.short $latestSignalComparisonEntry.base.full) '') } else { '' } headRef = if ($latestSignalComparisonEntry) { [string](Coalesce (Coalesce $latestSignalComparisonEntry.head.short $latestSignalComparisonEntry.head.full) '') } else { '' } - } + baseSubject = if ($latestSignalComparisonEntry) { [string](Coalesce $latestSignalComparisonEntry.base.subject '') } else { '' } + headSubject = if ($latestSignalComparisonEntry) { [string](Coalesce $latestSignalComparisonEntry.head.subject '') } else { '' } + } + reviewSequence = @( + $decisionReviewSequence | ForEach-Object { + [ordered]@{ + index = [int](Coalesce $_.index 0) + baseRef = [string](Coalesce $_.baseRef '') + headRef = [string](Coalesce $_.headRef '') + baseSubject = [string](Coalesce $_.baseSubject '') + headSubject = [string](Coalesce $_.headSubject '') + } + } + ) signalPairs = @($signalComparisonEntries | ForEach-Object { [int](Coalesce $_.index 0) }) collapsedPairs = @($collapsedComparisonEntries | ForEach-Object { [int](Coalesce $_.index 0) }) focusBuckets = @($decisionFocusBuckets.ToArray()) From 0e99c52b0705982eff7ea483b09e11d8c18efcbb Mon Sep 17 00:00:00 2001 From: Sergio Velderrain Date: Mon, 30 Mar 2026 20:41:49 -0700 Subject: [PATCH 10/16] Expose VI history decision chronology (#2063) * Add VI history decision statement * Expose VI history decision chronology --------- Co-authored-by: svelderrainruiz --- README.md | 6 +- docs/knowledgebase/VICompare-Refs-Workflow.md | 3 + ...parevi-tools-history-facade-v1.schema.json | 50 ++++- tests/CompareVI.History.Tests.ps1 | 21 +++ tests/Render-VIHistoryReport.Tests.ps1 | 24 ++- tools/Render-VIHistoryReport.ps1 | 173 +++++++++++++++--- 6 files changed, 242 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index ef942cc3f..492e826b2 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,11 @@ renders, `history-report-html`). Downstream workflows and reusable snippets can consume those keys to surface the Markdown/HTML report or to dispatch follow-up automation without spelunking the artifacts. When the renderer is unavailable, `Compare-VIHistory.ps1` writes a lightweight fallback report so the Markdown -output key always resolves to a readable summary. +output key always resolves to a readable summary. The stable +`history-summary-json` facade and rendered history report now also surface the +decision statement, the newest meaningful pair, and pair chronology (refs, +subjects, and commit dates) so reviewers can answer what changed, when it +changed, and whether it matters without opening raw manifests first. Provide the optional `notify_issue` input when dispatching the workflow to post the same summary table to a GitHub issue for stakeholders. diff --git a/docs/knowledgebase/VICompare-Refs-Workflow.md b/docs/knowledgebase/VICompare-Refs-Workflow.md index e2a6bce2f..41a7f39a5 100644 --- a/docs/knowledgebase/VICompare-Refs-Workflow.md +++ b/docs/knowledgebase/VICompare-Refs-Workflow.md @@ -220,6 +220,9 @@ gh workflow run vi-compare-refs.yml ` `Invoke-CompareVIHistoryFacade`. When HTML rendering is skipped or fails, the Markdown path still points at the fallback report so consumers always have a summary to ingest. A compressed `category-counts-json` blob is also published so downstream automation can react to runs dominated by cosmetic noise without re-reading the manifests. + The facade's `decisionGuidance` block now carries a human-readable `decisionStatement`, the newest surfaced pair, + and the review sequence with refs, subjects, and commit dates so downstream consumers can answer what changed, when, + and whether it matters from one stabilized payload. - History summary JSON (`tests/results/pr-vi-history/vi-history-summary.json`) now adds: - `targets[].reportImages` for per-target extraction metadata. - `pairTimeline[]` with additive per-pair contract fields: diff --git a/docs/schemas/comparevi-tools-history-facade-v1.schema.json b/docs/schemas/comparevi-tools-history-facade-v1.schema.json index 817c66f81..657069432 100644 --- a/docs/schemas/comparevi-tools-history-facade-v1.schema.json +++ b/docs/schemas/comparevi-tools-history-facade-v1.schema.json @@ -153,6 +153,7 @@ "decisionGuidance": { "type": "object", "required": [ + "decisionStatement", "reviewPriority", "latestPair", "latestSignalPair", @@ -163,6 +164,9 @@ "contextBuckets" ], "properties": { + "decisionStatement": { + "type": "string" + }, "reviewPriority": { "type": "string" }, @@ -170,7 +174,13 @@ "type": "object", "required": [ "index", - "status" + "status", + "baseRef", + "headRef", + "baseSubject", + "headSubject", + "baseDate", + "headDate" ], "properties": { "index": { @@ -179,6 +189,24 @@ }, "status": { "type": "string" + }, + "baseRef": { + "type": "string" + }, + "headRef": { + "type": "string" + }, + "baseSubject": { + "type": "string" + }, + "headSubject": { + "type": "string" + }, + "baseDate": { + "type": "string" + }, + "headDate": { + "type": "string" } }, "additionalProperties": false @@ -190,7 +218,9 @@ "baseRef", "headRef", "baseSubject", - "headSubject" + "headSubject", + "baseDate", + "headDate" ], "properties": { "index": { @@ -208,6 +238,12 @@ }, "headSubject": { "type": "string" + }, + "baseDate": { + "type": "string" + }, + "headDate": { + "type": "string" } }, "additionalProperties": false @@ -221,7 +257,9 @@ "baseRef", "headRef", "baseSubject", - "headSubject" + "headSubject", + "baseDate", + "headDate" ], "properties": { "index": { @@ -239,6 +277,12 @@ }, "headSubject": { "type": "string" + }, + "baseDate": { + "type": "string" + }, + "headDate": { + "type": "string" } } } diff --git a/tests/CompareVI.History.Tests.ps1 b/tests/CompareVI.History.Tests.ps1 index ae008ec4d..b250fd35d 100644 --- a/tests/CompareVI.History.Tests.ps1 +++ b/tests/CompareVI.History.Tests.ps1 @@ -574,6 +574,27 @@ exit 0 $aggregate.stats.signalDiffs | Should -Be 1 $aggregate.stats.noiseCollapsed | Should -Be 0 + + $historySummaryPath = Join-Path $rd 'history-summary.json' + Test-Path -LiteralPath $historySummaryPath | Should -BeTrue + $historySummary = Get-Content -LiteralPath $historySummaryPath -Raw | ConvertFrom-Json -Depth 12 + $historySummary.decisionGuidance.decisionStatement | Should -Match '^Start at pair 1; it is the newest meaningful change\. It touches ' + $historySummary.decisionGuidance.latestPair.status | Should -Be 'signal-review' + $historySummary.decisionGuidance.latestPair.baseDate | Should -Not -BeNullOrEmpty + $historySummary.decisionGuidance.latestPair.headDate | Should -Not -BeNullOrEmpty + $historySummary.decisionGuidance.latestSignalPair.index | Should -Be 1 + $historySummary.decisionGuidance.latestSignalPair.baseDate | Should -Not -BeNullOrEmpty + $historySummary.decisionGuidance.latestSignalPair.headDate | Should -Not -BeNullOrEmpty + @($historySummary.decisionGuidance.reviewSequence).Count | Should -Be 1 + $historySummary.decisionGuidance.reviewSequence[0].baseDate | Should -Not -BeNullOrEmpty + $historySummary.decisionGuidance.reviewSequence[0].headDate | Should -Not -BeNullOrEmpty + + $historyMd = Get-Content -LiteralPath (Join-Path $rd 'history-report.md') -Raw + $historyMd | Should -Match 'Decision statement' + $historyMd | Should -Match 'Review first' + $historyMd | Should -Match 'Review sequence' + $historyMd | Should -Match 'pair 1 \(' + $historyMd | Should -Match 'T\d{2}:\d{2}:\d{2}' } finally { if ($null -eq $previousDiff) { Remove-Item Env:STUB_COMPARE_DIFF -ErrorAction SilentlyContinue diff --git a/tests/Render-VIHistoryReport.Tests.ps1 b/tests/Render-VIHistoryReport.Tests.ps1 index 7ceecbc0b..d05351cd2 100644 --- a/tests/Render-VIHistoryReport.Tests.ps1 +++ b/tests/Render-VIHistoryReport.Tests.ps1 @@ -108,11 +108,13 @@ Describe 'Render-VIHistoryReport.ps1' -Tag 'Unit' { full = 'aaa111111111' short = 'aaa1111' subject= 'Clean base commit' + date = '2026-03-09T09:00:00Z' } head = @{ full = 'bbb222222222' short = 'bbb2222' subject= 'Clean head commit' + date = '2026-03-10T10:00:00Z' } lineage = [ordered]@{ type = 'touch-history' @@ -142,11 +144,13 @@ Describe 'Render-VIHistoryReport.ps1' -Tag 'Unit' { full = 'abc123456789' short = 'abc1234' subject= 'Base commit' + date = '2026-03-11T11:00:00Z' } head = @{ full = 'def987654321' short = 'def9876' subject= 'Head commit' + date = '2026-03-12T12:00:00Z' } lineage = [ordered]@{ type = 'merge-parent' @@ -217,11 +221,15 @@ Describe 'Render-VIHistoryReport.ps1' -Tag 'Unit' { $markdown | Should -Match '\| Mode Sensitivity \| `single-mode-observed` \|' $markdown | Should -Match '\| Outcome Labels \| `clean`, `signal-diff` \|' $markdown | Should -Match '## Decision guidance' + $markdown | Should -Match 'Decision statement' + $markdown | Should -Match 'Start at pair 2; it is the newest meaningful change' $markdown | Should -Match 'Review first' $markdown | Should -Match 'Review sequence' - $markdown | Should -Match 'pair 2 \(Base commit -> Head commit\)' + $markdown | Should -Match 'pair 2 \(abc1234 \(Base commit; 2026-03-11T11:00:00\+00:00\) -> def9876 \(Head commit; 2026-03-12T12:00:00\+00:00\)\)' + $markdown | Should -Match 'pair 2 \(Base commit @ 2026-03-11T11:00:00\+00:00 -> Head commit @ 2026-03-12T12:00:00\+00:00\)' $markdown | Should -Match '\| Mode \| Processed \| Diffs \| Signal \| Collapsed Noise \| Missing \| Categories \| Buckets \| Flags \|' $markdown | Should -Match '\| Mode \| Pair \| Lineage \| Base \| Head \| Diff \| Duration \(s\) \| Categories \| Buckets \| Report \| Highlights \|' + $markdown | Should -Match 'aaa1111 \(Clean base commit; 2026-03-09T09:00:00\+00:00\)' $markdown | Should -Match 'Touch history' $html = Get-Content -LiteralPath $htmlPath -Raw @@ -239,8 +247,11 @@ Describe 'Render-VIHistoryReport.ps1' -Tag 'Unit' { $html | Should -Match 'Outcome Labels' $html | Should -Match 'clean, signal-diff' $html | Should -Match 'Review first' + $html | Should -Match 'Decision statement' + $html | Should -Match 'Start at pair 2; it is the newest meaningful change' $html | Should -Match 'Review sequence' - $html | Should -Match 'pair 2 \(Base commit -> Head commit\)' + $html | Should -Match 'pair 2 \(abc1234 \(Base commit; 2026-03-11T11:00:00\+00:00\) -> def9876 \(Head commit; 2026-03-12T12:00:00\+00:00\)\)' + $html | Should -Match 'pair 2 \(Base commit @ 2026-03-11T11:00:00\+00:00 -> Head commit @ 2026-03-12T12:00:00\+00:00\)' $html | Should -Match '' $html | Should -Match '' $html | Should -Match '' @@ -267,17 +278,24 @@ Describe 'Render-VIHistoryReport.ps1' -Tag 'Unit' { $historySummaryLine | Should -Not -BeNullOrEmpty $historySummaryPath = (($historySummaryLine -split '=', 2)[1]).Trim() Test-Path -LiteralPath $historySummaryPath | Should -BeTrue - $historySummary = Get-Content -LiteralPath $historySummaryPath -Raw | ConvertFrom-Json -Depth 12 + $historySummary = Get-Content -LiteralPath $historySummaryPath -Raw | ConvertFrom-Json -Depth 12 -DateKind String $historySummary.target.sourceBranchRef | Should -Be 'feature/history-source' $historySummary.target.branchBudget.maxCommitCount | Should -Be 64 $historySummary.target.branchBudget.commitCount | Should -Be 3 + $historySummary.decisionGuidance.latestPair.baseDate | Should -Be '2026-03-09T09:00:00+00:00' + $historySummary.decisionGuidance.latestPair.headDate | Should -Be '2026-03-10T10:00:00+00:00' + $historySummary.decisionGuidance.decisionStatement | Should -Be 'Start at pair 2; it is the newest meaningful change. It touches Functional behavior.' $historySummary.decisionGuidance.latestSignalPair.index | Should -Be 2 $historySummary.decisionGuidance.latestSignalPair.baseSubject | Should -Be 'Base commit' $historySummary.decisionGuidance.latestSignalPair.headSubject | Should -Be 'Head commit' + $historySummary.decisionGuidance.latestSignalPair.baseDate | Should -Be '2026-03-11T11:00:00+00:00' + $historySummary.decisionGuidance.latestSignalPair.headDate | Should -Be '2026-03-12T12:00:00+00:00' @($historySummary.decisionGuidance.reviewSequence).Count | Should -Be 1 $historySummary.decisionGuidance.reviewSequence[0].index | Should -Be 2 $historySummary.decisionGuidance.reviewSequence[0].baseSubject | Should -Be 'Base commit' $historySummary.decisionGuidance.reviewSequence[0].headSubject | Should -Be 'Head commit' + $historySummary.decisionGuidance.reviewSequence[0].baseDate | Should -Be '2026-03-11T11:00:00+00:00' + $historySummary.decisionGuidance.reviewSequence[0].headDate | Should -Be '2026-03-12T12:00:00+00:00' } It 'preserves branch budget numeric fields when the source object is a hashtable' { diff --git a/tools/Render-VIHistoryReport.ps1 b/tools/Render-VIHistoryReport.ps1 index 719a3f5bd..8dcacd2f9 100644 --- a/tools/Render-VIHistoryReport.ps1 +++ b/tools/Render-VIHistoryReport.ps1 @@ -106,6 +106,102 @@ function Get-ShortSha { return $Value.Substring(0, $Length) } +function Format-DecisionTimestamp { + param($Value) + + if ($null -eq $Value) { return '' } + + if ($Value -is [DateTimeOffset]) { + return $Value.ToString('yyyy-MM-ddTHH:mm:ssK') + } + + if ($Value -is [DateTime]) { + return ([DateTimeOffset]$Value).ToString('yyyy-MM-ddTHH:mm:ssK') + } + + $text = [string]$Value + if ([string]::IsNullOrWhiteSpace($text)) { return '' } + + try { + $parsed = [DateTimeOffset]::Parse($text, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::RoundtripKind) + return $parsed.ToString('yyyy-MM-ddTHH:mm:ssK') + } catch { + return $text + } +} + +function New-DecisionPairMetadata { + param([AllowNull()][object]$Entry) + + if (-not $Entry) { + return [ordered]@{ + index = 0 + baseRef = '' + headRef = '' + baseSubject = '' + headSubject = '' + baseDate = '' + headDate = '' + } + } + + return [ordered]@{ + index = [int](Coalesce $Entry.index 0) + baseRef = [string](Coalesce (Coalesce $Entry.base.short $Entry.base.full) '') + headRef = [string](Coalesce (Coalesce $Entry.head.short $Entry.head.full) '') + baseSubject = [string](Coalesce $Entry.base.subject '') + headSubject = [string](Coalesce $Entry.head.subject '') + baseDate = [string](Format-DecisionTimestamp -Value $Entry.base.date) + headDate = [string](Format-DecisionTimestamp -Value $Entry.head.date) + } +} + +function Format-DecisionCommitLabel { + param( + [string]$Ref, + [string]$Subject, + [string]$Date + ) + + $label = [string](Coalesce $Ref 'n/a') + $details = New-Object System.Collections.Generic.List[string] + if (-not [string]::IsNullOrWhiteSpace($Subject)) { + $details.Add($Subject) | Out-Null + } + if (-not [string]::IsNullOrWhiteSpace($Date)) { + $details.Add($Date) | Out-Null + } + if ($details.Count -gt 0) { + $label = '{0} ({1})' -f $label, ([string]::Join('; ', @($details.ToArray()))) + } + return $label +} + +function Format-DecisionPairTimelineLabel { + param( + [AllowNull()][object]$Pair, + [switch]$UseSubjectsOnly + ) + + if (-not $Pair) { return 'pair n/a' } + + if ($UseSubjectsOnly.IsPresent) { + $baseLabel = if (-not [string]::IsNullOrWhiteSpace([string]$Pair.baseSubject)) { [string]$Pair.baseSubject } else { [string](Coalesce $Pair.baseRef 'n/a') } + if (-not [string]::IsNullOrWhiteSpace([string]$Pair.baseDate)) { + $baseLabel = '{0} @ {1}' -f $baseLabel, [string]$Pair.baseDate + } + $headLabel = if (-not [string]::IsNullOrWhiteSpace([string]$Pair.headSubject)) { [string]$Pair.headSubject } else { [string](Coalesce $Pair.headRef 'n/a') } + if (-not [string]::IsNullOrWhiteSpace([string]$Pair.headDate)) { + $headLabel = '{0} @ {1}' -f $headLabel, [string]$Pair.headDate + } + } else { + $baseLabel = Format-DecisionCommitLabel -Ref ([string](Coalesce $Pair.baseRef 'n/a')) -Subject ([string](Coalesce $Pair.baseSubject '')) -Date ([string](Coalesce $Pair.baseDate '')) + $headLabel = Format-DecisionCommitLabel -Ref ([string](Coalesce $Pair.headRef 'n/a')) -Subject ([string](Coalesce $Pair.headSubject '')) -Date ([string](Coalesce $Pair.headDate '')) + } + + return 'pair {0} ({1} -> {2})' -f (Coalesce $Pair.index 'n/a'), $baseLabel, $headLabel +} + function Get-LineageLabel { param( [object]$Lineage, @@ -1288,18 +1384,14 @@ $decisionReviewPriority = if ($signalComparisonEntries.Count -gt 0) { } else { 'no-diff' } +$decisionLatestPair = New-DecisionPairMetadata -Entry $latestComparisonEntry +$latestSignalComparisonEntry = if ($signalComparisonEntries.Count -gt 0) { $signalComparisonEntries[0] } else { $null } +$decisionLatestSignalPair = New-DecisionPairMetadata -Entry $latestSignalComparisonEntry $decisionReviewSequence = @( $signalComparisonEntries | ForEach-Object { - [ordered]@{ - index = [int](Coalesce $_.index 0) - baseRef = [string](Coalesce (Coalesce $_.base.short $_.base.full) '') - headRef = [string](Coalesce (Coalesce $_.head.short $_.head.full) '') - baseSubject = [string](Coalesce $_.base.subject '') - headSubject = [string](Coalesce $_.head.subject '') - } + New-DecisionPairMetadata -Entry $_ } ) -$latestSignalComparisonEntry = if ($signalComparisonEntries.Count -gt 0) { $signalComparisonEntries[0] } else { $null } $decisionLatestStatus = 'n/a' if ($latestComparisonEntry) { $latestResultNode = $latestComparisonEntry.result @@ -1315,23 +1407,40 @@ if ($latestComparisonEntry) { $decisionLatestStatus = 'clean' } } +$decisionStatement = '' +if ($latestComparisonEntry -and $decisionLatestStatus -eq 'collapsed-noise' -and $latestSignalComparisonEntry) { + $decisionStatement = 'Newest VI touch is metadata-only.' + if ($decisionFocusBuckets.Count -gt 0) { + $decisionStatement = '{0} Start at pair {1}; newest meaningful change touches {2}.' -f $decisionStatement, (Coalesce $latestSignalComparisonEntry.index 'n/a'), ([string]::Join(', ', @($decisionFocusBuckets.ToArray()))) + } else { + $decisionStatement = '{0} Start at pair {1}; it is the newest meaningful change.' -f $decisionStatement, (Coalesce $latestSignalComparisonEntry.index 'n/a') + } +} elseif ($latestSignalComparisonEntry) { + $decisionStatement = 'Start at pair {0}; it is the newest meaningful change.' -f (Coalesce $latestSignalComparisonEntry.index 'n/a') + if ($decisionFocusBuckets.Count -gt 0) { + $decisionStatement = '{0} It touches {1}.' -f $decisionStatement, ([string]::Join(', ', @($decisionFocusBuckets.ToArray()))) + } +} elseif ($collapsedComparisonEntries.Count -gt 0) { + $decisionStatement = 'All observed pairs are metadata-only under the current noise policy.' +} else { + $decisionStatement = 'No meaningful differences were observed in the selected history window.' +} if ($sortedComparisons.Count -gt 0) { $summaryLines.Add('') $summaryLines.Add('## Decision guidance') $summaryLines.Add('') + $summaryLines.Add(('- Decision statement: {0}' -f $decisionStatement)) $summaryLines.Add(('- Review priority: `{0}`' -f $decisionReviewPriority)) if ($latestComparisonEntry) { - $summaryLines.Add(('- Latest pair: `pair {0}` is `{1}`' -f (Coalesce $latestComparisonEntry.index 'n/a'), $decisionLatestStatus)) + $summaryLines.Add(('- Latest pair: `{0}` is `{1}`' -f (Format-DecisionPairTimelineLabel -Pair $decisionLatestPair), $decisionLatestStatus)) } if ($latestSignalComparisonEntry) { - $latestSignalBaseRef = [string](Coalesce (Coalesce $latestSignalComparisonEntry.base.short $latestSignalComparisonEntry.base.full) 'n/a') - $latestSignalHeadRef = [string](Coalesce (Coalesce $latestSignalComparisonEntry.head.short $latestSignalComparisonEntry.head.full) 'n/a') - $summaryLines.Add(('- Review first: `pair {0}` (`{1}` -> `{2}`)' -f (Coalesce $latestSignalComparisonEntry.index 'n/a'), $latestSignalBaseRef, $latestSignalHeadRef)) + $summaryLines.Add(('- Review first: `{0}`' -f (Format-DecisionPairTimelineLabel -Pair $decisionLatestSignalPair))) } if ($decisionReviewSequence.Count -gt 0) { $reviewSequenceLabels = @( $decisionReviewSequence | ForEach-Object { - 'pair {0} ({1} -> {2})' -f $_.index, (Coalesce $_.baseSubject 'n/a'), (Coalesce $_.headSubject 'n/a') + Format-DecisionPairTimelineLabel -Pair $_ -UseSubjectsOnly } ) $summaryLines.Add(('- Review sequence: `{0}`' -f ([string]::Join('; ', $reviewSequenceLabels)))) @@ -1372,10 +1481,8 @@ if ($comparisons.Count -gt 0) { } if ([string]::IsNullOrWhiteSpace($lineageLabel)) { $lineageLabel = 'Mainline' } - $baseRef = Coalesce $entry.base.short $entry.base.full - if ($entry.base.subject) { $baseRef = '{0} ({1})' -f $baseRef, $entry.base.subject } - $headRef = Coalesce $entry.head.short $entry.head.full - if ($entry.head.subject) { $headRef = '{0} ({1})' -f $headRef, $entry.head.subject } + $baseRef = Format-DecisionCommitLabel -Ref ([string](Coalesce $entry.base.short $entry.base.full)) -Subject ([string](Coalesce $entry.base.subject '')) -Date (Format-DecisionTimestamp -Value $entry.base.date) + $headRef = Format-DecisionCommitLabel -Ref ([string](Coalesce $entry.head.short $entry.head.full)) -Subject ([string](Coalesce $entry.head.subject '')) -Date (Format-DecisionTimestamp -Value $entry.head.date) $resultNode = $entry.result $hasDiffValue = $resultNode -and $resultNode.PSObject.Properties['diff'] $diffValue = $hasDiffValue -and ($resultNode.diff -eq $true) @@ -1686,19 +1793,18 @@ if ($emitHtml -and $HtmlPath) { if ($sortedComparisons.Count -gt 0) { [void]$htmlBuilder.AppendLine('

    Decision guidance

    ') [void]$htmlBuilder.AppendLine('
      ') + [void]$htmlBuilder.AppendLine(('
    • Decision statement{0}
    • ' -f (ConvertTo-HtmlSafe $decisionStatement))) [void]$htmlBuilder.AppendLine(('
    • Review priority{0}
    • ' -f (Format-HtmlCodeList -Values @($decisionReviewPriority)))) if ($latestComparisonEntry) { - [void]$htmlBuilder.AppendLine(('
    • Latest pairpair {0} is {1}
    • ' -f (ConvertTo-HtmlSafe (Coalesce $latestComparisonEntry.index 'n/a')), (ConvertTo-HtmlSafe $decisionLatestStatus))) + [void]$htmlBuilder.AppendLine(('
    • Latest pair{0} is {1}
    • ' -f (ConvertTo-HtmlSafe (Format-DecisionPairTimelineLabel -Pair $decisionLatestPair)), (ConvertTo-HtmlSafe $decisionLatestStatus))) } if ($latestSignalComparisonEntry) { - $latestSignalBaseRef = [string](Coalesce (Coalesce $latestSignalComparisonEntry.base.short $latestSignalComparisonEntry.base.full) 'n/a') - $latestSignalHeadRef = [string](Coalesce (Coalesce $latestSignalComparisonEntry.head.short $latestSignalComparisonEntry.head.full) 'n/a') - [void]$htmlBuilder.AppendLine(('
    • Review firstpair {0} ({1} -> {2})
    • ' -f (ConvertTo-HtmlSafe (Coalesce $latestSignalComparisonEntry.index 'n/a')), (ConvertTo-HtmlSafe $latestSignalBaseRef), (ConvertTo-HtmlSafe $latestSignalHeadRef))) + [void]$htmlBuilder.AppendLine(('
    • Review first{0}
    • ' -f (ConvertTo-HtmlSafe (Format-DecisionPairTimelineLabel -Pair $decisionLatestSignalPair)))) } if ($decisionReviewSequence.Count -gt 0) { $reviewSequenceLabels = @( $decisionReviewSequence | ForEach-Object { - 'pair {0} ({1} -> {2})' -f $_.index, (Coalesce $_.baseSubject 'n/a'), (Coalesce $_.headSubject 'n/a') + Format-DecisionPairTimelineLabel -Pair $_ -UseSubjectsOnly } ) [void]$htmlBuilder.AppendLine(('
    • Review sequence{0}
    • ' -f (ConvertTo-HtmlSafe ([string]::Join('; ', $reviewSequenceLabels))))) @@ -1997,17 +2103,26 @@ $historySummary = [ordered]@{ outcomeLabels = @(Get-SortedUniqueStringArray -Value $outcomeLabels) } decisionGuidance = [ordered]@{ + decisionStatement = [string]$decisionStatement reviewPriority = [string]$decisionReviewPriority latestPair = [ordered]@{ - index = if ($latestComparisonEntry) { [int](Coalesce $latestComparisonEntry.index 0) } else { 0 } + index = [int](Coalesce $decisionLatestPair.index 0) status = [string]$decisionLatestStatus + baseRef = [string](Coalesce $decisionLatestPair.baseRef '') + headRef = [string](Coalesce $decisionLatestPair.headRef '') + baseSubject = [string](Coalesce $decisionLatestPair.baseSubject '') + headSubject = [string](Coalesce $decisionLatestPair.headSubject '') + baseDate = [string](Coalesce $decisionLatestPair.baseDate '') + headDate = [string](Coalesce $decisionLatestPair.headDate '') } latestSignalPair = [ordered]@{ - index = if ($latestSignalComparisonEntry) { [int](Coalesce $latestSignalComparisonEntry.index 0) } else { 0 } - baseRef = if ($latestSignalComparisonEntry) { [string](Coalesce (Coalesce $latestSignalComparisonEntry.base.short $latestSignalComparisonEntry.base.full) '') } else { '' } - headRef = if ($latestSignalComparisonEntry) { [string](Coalesce (Coalesce $latestSignalComparisonEntry.head.short $latestSignalComparisonEntry.head.full) '') } else { '' } - baseSubject = if ($latestSignalComparisonEntry) { [string](Coalesce $latestSignalComparisonEntry.base.subject '') } else { '' } - headSubject = if ($latestSignalComparisonEntry) { [string](Coalesce $latestSignalComparisonEntry.head.subject '') } else { '' } + index = [int](Coalesce $decisionLatestSignalPair.index 0) + baseRef = [string](Coalesce $decisionLatestSignalPair.baseRef '') + headRef = [string](Coalesce $decisionLatestSignalPair.headRef '') + baseSubject = [string](Coalesce $decisionLatestSignalPair.baseSubject '') + headSubject = [string](Coalesce $decisionLatestSignalPair.headSubject '') + baseDate = [string](Coalesce $decisionLatestSignalPair.baseDate '') + headDate = [string](Coalesce $decisionLatestSignalPair.headDate '') } reviewSequence = @( $decisionReviewSequence | ForEach-Object { @@ -2017,6 +2132,8 @@ $historySummary = [ordered]@{ headRef = [string](Coalesce $_.headRef '') baseSubject = [string](Coalesce $_.baseSubject '') headSubject = [string](Coalesce $_.headSubject '') + baseDate = [string](Coalesce $_.baseDate '') + headDate = [string](Coalesce $_.headDate '') } } ) From c648f4620f07c7334b8b51a3de03d99c921d36a3 Mon Sep 17 00:00:00 2001 From: Sergio Velderrain Date: Mon, 30 Mar 2026 21:07:15 -0700 Subject: [PATCH 11/16] Run Windows VI history proof on self-hosted ingress (#2065) * Route Windows VI history proof to self-hosted ingress * Export self-hosted Windows lane plan outputs --------- Co-authored-by: svelderrainruiz --- .github/workflows/validate.yml | 121 +++++++++++++++--- docs/DEVELOPER_GUIDE.md | 14 +- docs/ENVIRONMENT.md | 16 ++- docs/SELFHOSTED_CI_SETUP.md | 16 ++- docs/knowledgebase/FEATURE_BRANCH_POLICY.md | 16 ++- tools/Resolve-SelfHostedWindowsLanePlan.ps1 | 34 +++-- tools/Test-WindowsNI2026q1HostPreflight.ps1 | 9 +- tools/policy/runner-capability-routing.json | 7 + .../docker-labview-path-contract.test.mjs | 12 +- .../runner-capability-routing-policy.test.mjs | 4 + ...date-vi-history-dispatch-contract.test.mjs | 24 ++-- 11 files changed, 203 insertions(+), 70 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 5f784c99b..e7def6867 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -1496,6 +1496,9 @@ jobs: execution_model: ${{ steps.plan.outputs.execution_model }} runner_image: ${{ steps.plan.outputs.runner_image }} expected_context: ${{ steps.plan.outputs.expected_context }} + expected_os: ${{ steps.plan.outputs.expected_os }} + required_labels: ${{ steps.plan.outputs.required_labels }} + matching_runner_count: ${{ steps.plan.outputs.matching_runner_count }} steps: - uses: actions/checkout@v5 with: @@ -1513,18 +1516,37 @@ jobs: with: phase: J2 results-dir: tests/results - - name: Resolve portable hosted Windows lane + - name: Resolve self-hosted Windows Docker lane id: plan shell: pwsh + env: + GITHUB_TOKEN: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }} run: | $resultsRoot = 'tests/results/_agent/vi-history-dispatch' New-Item -ItemType Directory -Path $resultsRoot -Force | Out-Null - $planPath = Join-Path $resultsRoot 'validate-vi-history-windows-hosted-plan.json' - pwsh -NoLogo -NoProfile -File tools/Resolve-HostedWindowsLanePlan.ps1 ` - -RunnerImage 'windows-2022' ` - -ContainerImage 'nationalinstruments/labview:2026q1-windows' ` - -ExpectedContext 'default' ` + $planPath = Join-Path $resultsRoot 'validate-vi-history-windows-docker-plan.json' + $requiredLabels = @( + 'self-hosted', + 'Windows', + 'X64', + 'comparevi', + 'capability-ingress', + 'docker-lane' + ) + pwsh -NoLogo -NoProfile -File tools/Resolve-SelfHostedWindowsLanePlan.ps1 ` + -Repository '${{ github.repository }}' ` + -RequiredLabels $requiredLabels ` + -ExecutionModel 'self-hosted-windows-docker-lane' ` + -RunnerImage 'self-hosted-windows-docker-lane' ` + -ExpectedContext 'desktop-windows' ` -ExpectedOs 'windows' ` + -RequiredHealthReceipts @() ` + -Notes @( + 'Availability means an online, idle repository runner advertises the ingress plus docker-lane labels.', + 'The lane may mutate Docker Desktop into the Windows engine and then restores the starting context after proof capture.' + ) ` + -Token $env:GITHUB_TOKEN ` -OutputJsonPath $planPath ` -GitHubOutputPath $env:GITHUB_OUTPUT ` -StepSummaryPath $env:GITHUB_STEP_SUMMARY @@ -1534,15 +1556,16 @@ jobs: run: | if ($env:GITHUB_STEP_SUMMARY) { $lines = @( - '### VI History Scenarios (Windows hosted plan)', + '### VI History Scenarios (Windows self-hosted Docker plan)', '', ('- execute_lanes: `{0}`' -f '${{ needs.vi-history-scenarios-plan.outputs.execute_lanes }}'), ('- lane_available: `{0}`' -f '${{ steps.plan.outputs.available }}'), ('- lane_status: `{0}`' -f '${{ steps.plan.outputs.status }}'), ('- skip_reason: `{0}`' -f '${{ steps.plan.outputs.skip_reason }}'), - ('- hosted_model: `{0}`' -f '${{ steps.plan.outputs.execution_model }}'), + ('- execution_model: `{0}`' -f '${{ steps.plan.outputs.execution_model }}'), ('- runner_image: `{0}`' -f '${{ steps.plan.outputs.runner_image }}'), - '- hosted model: agents can dispatch this portable hosted Windows lane while continuing on local Linux or Windows Docker Desktop lanes.' + ('- required_labels: `{0}`' -f '${{ steps.plan.outputs.required_labels }}'), + '- self-hosted docker model: validate 64-bit LabVIEW in the Windows container while the LV32 lane runs in parallel as the native host-plane reference.' ) $lines -join "`n" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append } @@ -1551,20 +1574,24 @@ jobs: if: always() uses: actions/upload-artifact@v7 with: - name: validate-vi-history-windows-hosted-plan - path: tests/results/_agent/vi-history-dispatch/validate-vi-history-windows-hosted-plan.json + name: validate-vi-history-windows-docker-plan + path: tests/results/_agent/vi-history-dispatch/validate-vi-history-windows-docker-plan.json if-no-files-found: error vi-history-scenarios-windows: needs: [smoke-gate, lint, session-index, session-index-v2-contract, vi-history-scenarios-plan, vi-history-scenarios-windows-plan] if: needs.smoke-gate.outputs.skip != 'true' && needs.vi-history-scenarios-plan.outputs.execute_lanes == 'true' && needs.vi-history-scenarios-windows-plan.outputs.available == 'true' - runs-on: windows-2022 + runs-on: [self-hosted, Windows, X64, comparevi, capability-ingress, docker-lane] + continue-on-error: true timeout-minutes: 50 permissions: + actions: read contents: read env: NI_WINDOWS_IMAGE: nationalinstruments/labview:2026q1-windows NI_WINDOWS_LABVIEW_PATH: C:\Program Files\National Instruments\LabVIEW 2026\LabVIEW.exe + GITHUB_TOKEN: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }} defaults: run: shell: pwsh @@ -1573,13 +1600,14 @@ jobs: run: | if ($env:GITHUB_STEP_SUMMARY) { $lines = @( - '### VI History Scenarios (Windows lane)', + '### VI History Scenarios (Windows self-hosted Docker lane)', '', ('- execute_lanes: `{0}`' -f '${{ needs.vi-history-scenarios-plan.outputs.execute_lanes }}'), - ('- hosted_status: `{0}`' -f '${{ needs.vi-history-scenarios-windows-plan.outputs.status }}'), + ('- plan_status: `{0}`' -f '${{ needs.vi-history-scenarios-windows-plan.outputs.status }}'), ('- runner_image: `{0}`' -f '${{ needs.vi-history-scenarios-windows-plan.outputs.runner_image }}'), ('- expected_context: `{0}`' -f '${{ needs.vi-history-scenarios-windows-plan.outputs.expected_context }}'), - '- image pull budget: Windows image hydration is materially slower than the Linux lane, so keep local lanes active while hosted proof runs.' + ('- required_labels: `{0}`' -f '${{ needs.vi-history-scenarios-windows-plan.outputs.required_labels }}'), + '- self-hosted 64-bit proof runs on the ingress host and pairs with the parallel LV32 lane so 32-bit host-plane drift has an immediate native reference.' ) $lines -join "`n" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append } @@ -1601,7 +1629,31 @@ jobs: with: phase: J2 results-dir: tests/results - - name: Collect hosted Windows runner health + - name: Validate self-hosted runner label contract + shell: pwsh + run: | + $resultsRoot = Join-Path $env:GITHUB_WORKSPACE 'tests/results/local-parity/windows' + New-Item -ItemType Directory -Path $resultsRoot -Force | Out-Null + $requiredLabels = @( + 'self-hosted', + 'Windows', + 'X64', + 'comparevi', + 'capability-ingress', + 'docker-lane' + ) + foreach ($label in $requiredLabels) { + $contractPath = Join-Path $resultsRoot ("runner-label-contract-{0}.json" -f $label) + pwsh -NoLogo -NoProfile -File tools/Assert-RunnerLabelContract.ps1 ` + -Repository '${{ github.repository }}' ` + -RunnerName $env:RUNNER_NAME ` + -RequiredLabel $label ` + -Token $env:GITHUB_TOKEN ` + -OutputJsonPath $contractPath ` + -GitHubOutputPath $env:GITHUB_OUTPUT ` + -StepSummaryPath $env:GITHUB_STEP_SUMMARY + } + - name: Collect self-hosted Windows runner health shell: pwsh run: | pwsh -NoLogo -NoProfile -File tools/Collect-RunnerHealth.ps1 ` @@ -1618,8 +1670,9 @@ jobs: pwsh -NoLogo -NoProfile -File tools/Test-WindowsNI2026q1HostPreflight.ps1 ` -Image $env:NI_WINDOWS_IMAGE ` -ResultsDir $resultsRoot ` - -ExecutionSurface 'github-hosted-windows' ` - -AllowUnavailable ` + -ExecutionSurface 'desktop-local' ` + -ManageDockerEngine:$true ` + -AllowHostEngineMutation:$true ` -OutputJsonPath $summaryPath ` -GitHubOutputPath $env:GITHUB_OUTPUT ` -StepSummaryPath $env:GITHUB_STEP_SUMMARY @@ -1650,6 +1703,8 @@ jobs: -LabVIEWPath $env:NI_WINDOWS_LABVIEW_PATH ` -ReportPath $reportPath ` -TimeoutSeconds 600 ` + -ManageDockerEngine:$false ` + -AllowHostEngineMutation:$false ` -RuntimeEngineReadyTimeoutSeconds 180 ` -RuntimeEngineReadyPollSeconds 5 ` -RuntimeSnapshotPath $runtimeSnapshot @@ -1743,7 +1798,33 @@ jobs: if: always() && steps.windows-preflight.outputs.windows_host_preflight_status != 'ready' shell: pwsh run: | - Write-Host ("::notice::Skipping hosted Windows compare because preflight status was {0} ({1})." -f '${{ steps.windows-preflight.outputs.windows_host_preflight_status }}', '${{ steps.windows-preflight.outputs.windows_host_preflight_failure_class }}') + Write-Host ("::notice::Skipping self-hosted Windows Docker compare because preflight status was {0} ({1})." -f '${{ steps.windows-preflight.outputs.windows_host_preflight_status }}', '${{ steps.windows-preflight.outputs.windows_host_preflight_failure_class }}') + + - name: Restore Docker Desktop context after Windows proof + if: always() + shell: pwsh + run: | + $resultsRoot = 'tests/results/local-parity/windows' + $preflightPath = Join-Path $resultsRoot 'windows-ni-2026q1-host-preflight.json' + if (-not (Test-Path -LiteralPath $preflightPath -PathType Leaf)) { + Write-Host ("::notice::Skipping Docker Desktop restore because preflight artifact was not found at {0}" -f $preflightPath) + exit 0 + } + $preflight = Get-Content -LiteralPath $preflightPath -Raw | ConvertFrom-Json -Depth 20 + $restoreContext = if ($preflight.contexts.PSObject.Properties['start'] -and -not [string]::IsNullOrWhiteSpace([string]$preflight.contexts.start)) { + [string]$preflight.contexts.start + } else { + 'desktop-windows' + } + $restorePath = Join-Path $resultsRoot 'runtime-manager-restore-windows.json' + pwsh -NoLogo -NoProfile -File tools/Invoke-DockerRuntimeManager.ps1 ` + -ProbeScope 'windows' ` + -BootstrapWindowsImage:$false ` + -BootstrapLinuxImage:$false ` + -RestoreContext $restoreContext ` + -OutputJsonPath $restorePath ` + -GitHubOutputPath '' ` + -StepSummaryPath $env:GITHUB_STEP_SUMMARY | Out-Null - name: Upload VI history scenario artifacts (Windows lane) if: always() @@ -1759,6 +1840,8 @@ jobs: tests/results/local-parity/windows/container-export/** tests/results/local-parity/windows/windows-ni-2026q1-host-preflight.json tests/results/local-parity/windows/runtime-manager-compare-windows.json + tests/results/local-parity/windows/runtime-manager-restore-windows.json + tests/results/local-parity/windows/runner-label-contract-*.json tests/results/local-parity/windows/_agent/runner-health.json if-no-files-found: warn diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index df1423f90..43158d490 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -478,14 +478,12 @@ For each cut: `comparevi-history-bundle-certification` follows the same routing. `vi-history-scenarios-*` runs for `compare-engine-history`, `docker-vi-history`, `mixed-runtime`, `unclassified`, and explicit manual dispatches; the final VI-history plan still honors `history_scenario_set`. -- Hosted Windows mirror proof now lives in `Validate` as the non-required `vi-history-scenarios-windows` lane. - That lane runs on GitHub-hosted `windows-2022`, hydrates - `nationalinstruments/labview:2026q1-windows` with no repository runner dependency, and is expected to - take materially longer to pull than the Linux lane. -- If the hosted Windows runner cannot expose a Docker Windows daemon, the lane records - `windows_host_preflight_status = unavailable` and skips the heavy compare instead of poisoning - the current queue item with a non-required proof failure. - Agents can dispatch the hosted lane while continuing with the manual Linux or Windows Docker Desktop/WSL2 lanes locally. +- Windows mirror proof now lives in `Validate` as the non-required + `vi-history-scenarios-windows` lane on the self-hosted compare ingress host. + That lane validates the 64-bit Windows Docker image locally instead of burning + GitHub-hosted Windows image-pull time. +- `vi-history-scenarios-windows-lv32` runs in parallel and acts as the native 32-bit + reference for that Windows Docker proof, so 32-bit and 64-bit feedback arrive together. - `node tools/npm/run-script.mjs priority:lane:concurrency:plan` now reads the host-plane report, host RAM budget, and optional Docker runtime snapshot to recommend a safe concurrent hosted/manual bundle before those lanes are dispatched. diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index 2558e3eb3..727b54b3f 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -144,14 +144,16 @@ Notes: - The `windows-mirror-proof` local VI-history profile is pinned to this same image and is proof-only in the first slice; it is not a warm or accelerated lane. - Hosted CI now has a matching non-required Windows proof lane in `Validate`: - `vi-history-scenarios-windows`. It runs on GitHub-hosted `windows-2022`, bootstraps + `vi-history-scenarios-windows`. It runs on the self-hosted compare ingress + host with the `docker-lane` capability label, bootstraps `nationalinstruments/labview:2026q1-windows`, and uses the same canonical in-container LabVIEW path: `C:\Program Files\National Instruments\LabVIEW 2026\LabVIEW.exe`. -- If the GitHub-hosted runner cannot expose a usable Docker Windows daemon, the lane now records - `status = unavailable` in the preflight artifact and skips the heavy compare instead of blocking - unrelated integration work. -- Expect the hosted Windows image pull to be materially slower than the Linux lane. - Agents can dispatch the hosted lane while manually running the Linux or Windows Docker Desktop/WSL2 lanes on this host. +- `vi-history-scenarios-windows-lv32` runs in parallel on the same ingress host and serves + as the native 32-bit reference for the Windows Docker proof. +- The lane can mutate Docker Desktop into the Windows engine and restores the starting + context after the proof run so the ingress host does not stay stranded on the wrong daemon. +- Expect the self-hosted Windows image proof to remain materially slower than the Linux lane, + but it now spends local host time instead of GitHub-hosted Windows minutes. - Use `node tools/npm/run-script.mjs priority:lane:concurrency:plan` to turn the current host-plane, host-RAM, and Docker-runtime receipts into a recommended concurrent hosted/manual lane bundle before dispatching work. The plan now emits a `dockerRuntimeCutover` contract so you can tell whether a Linux-based @@ -197,6 +199,8 @@ Notes: `vi-history-scenarios-windows` lane locally: it runs the same NI Windows host preflight, then the same fixed fixture compare invocation against the canonical in-container LabVIEW path. +- That replay remains the fastest way to iterate on the Windows Docker proof locally; the + CI lane exists to keep the parallel 64-bit proof aligned with the LV32 reference job. Common remediation: diff --git a/docs/SELFHOSTED_CI_SETUP.md b/docs/SELFHOSTED_CI_SETUP.md index 9bea93442..6c745e827 100644 --- a/docs/SELFHOSTED_CI_SETUP.md +++ b/docs/SELFHOSTED_CI_SETUP.md @@ -99,8 +99,20 @@ today's compare jobs is: - `tools/policy/runner-capability-routing.json` That matrix keeps most compare jobs ingress-only, but -`.github/workflows/labview-cli-compare.yml` is now an explicit native 32-bit -consumer: +`.github/workflows/validate.yml`'s `vi-history-scenarios-windows` lane is now an +explicit Windows Docker proof consumer: + +- `runs-on: [self-hosted, Windows, X64, comparevi, capability-ingress, docker-lane]` +- it may mutate Docker Desktop into the Windows engine to prove `nationalinstruments/labview:2026q1-windows` +- it restores the starting Docker context after the proof completes + +The matrix also keeps the native 32-bit consumers explicit so they can run in +parallel with the 64-bit Windows Docker proof: + +- `.github/workflows/validate.yml` `vi-history-scenarios-windows-lv32` +- `.github/workflows/labview-cli-compare.yml` + +- `.github/workflows/labview-cli-compare.yml` consumer details: - `runs-on: [self-hosted, Windows, X64, comparevi, capability-ingress, labview-2026, lv32]` - emit `node tools/npm/run-script.mjs env:labview:2026:host-planes` diff --git a/docs/knowledgebase/FEATURE_BRANCH_POLICY.md b/docs/knowledgebase/FEATURE_BRANCH_POLICY.md index d37edde8c..5b9366cf3 100644 --- a/docs/knowledgebase/FEATURE_BRANCH_POLICY.md +++ b/docs/knowledgebase/FEATURE_BRANCH_POLICY.md @@ -1,7 +1,7 @@ # Feature Branch Enforcement & Merge Queue -| `develop` (live id may drift) | `refs/heads/develop` | Merge queue enabled (`merge_method=SQUASH`, `grouping=ALLGREEN`, build queue <=20 entries, 1-minute quiet window). Required checks: `lint`, `fixtures`, `Policy Guard (Upstream) / policy-guard`, `vi-history-scenarios-linux`, `commit-integrity`. Non-required hosted proof lanes may run alongside the queue contract, including `session-index`, `issue-snapshot`, `semver`, `agent-review-policy`, `hook-parity`, and `vi-history-scenarios-windows` on GitHub-hosted `windows-2022`. Copilot review settings are no longer enforced through policy; draft/ready review semantics are repo-owned and validated by `agent-review-policy`. | +| `develop` (live id may drift) | `refs/heads/develop` | Merge queue enabled (`merge_method=SQUASH`, `grouping=ALLGREEN`, build queue <=20 entries, 1-minute quiet window). Required checks: `lint`, `fixtures`, `Policy Guard (Upstream) / policy-guard`, `vi-history-scenarios-linux`, `commit-integrity`. Non-required supporting lanes may run alongside the queue contract, including `session-index`, `issue-snapshot`, `semver`, `agent-review-policy`, `hook-parity`, plus the self-hosted Windows Docker and LV32 VI-history proofs that run in parallel on the compare ingress host. Copilot review settings are no longer enforced through policy; draft/ready review semantics are repo-owned and validated by `agent-review-policy`. | ## Purpose @@ -129,10 +129,16 @@ checked into `tools/priority/policy.json` so `priority:policy` stays authoritati - **Required checks**: `lint`, `fixtures`, `Policy Guard (Upstream) / policy-guard`, `vi-history-scenarios-linux`, `commit-integrity`. - **Supporting non-required lanes**: `session-index`, `issue-snapshot`, `semver`, - `agent-review-policy`, `hook-parity`, plus hosted Windows proving. -- **Non-required hosted proof**: `vi-history-scenarios-windows` may run on GitHub-hosted - `windows-2022` to validate `nationalinstruments/labview:2026q1-windows`. Agents may - dispatch that hosted lane while manually running the Linux or Windows Docker Desktop/WSL2 lanes on this host. + `agent-review-policy`, `hook-parity`, plus parallel Windows 64-bit and LV32 proving. +- **Non-required self-hosted Docker proof**: `vi-history-scenarios-windows` runs on the + compare ingress host with the `docker-lane` capability label to validate + `nationalinstruments/labview:2026q1-windows` without paying GitHub-hosted Windows + image-hydration time on every PR. The lane may flip Docker Desktop into the + Windows engine, capture the 64-bit container proof, and restore the starting + context afterward. +- **Parallel LV32 reference proof**: `vi-history-scenarios-windows-lv32` runs beside the + Docker lane so native 32-bit LabVIEW and the 64-bit Windows container can be observed + together without serializing the Windows-side feedback loop. - **Admin bypass**: leave disabled; administrators should only intervene when `priority:policy` confirms parity. - **Reapply**: Use `node tools/npm/run-script.mjs priority:policy -- --apply` to push the manifest configuration when drift is detected. diff --git a/tools/Resolve-SelfHostedWindowsLanePlan.ps1 b/tools/Resolve-SelfHostedWindowsLanePlan.ps1 index 12a8f09d1..2da95068c 100644 --- a/tools/Resolve-SelfHostedWindowsLanePlan.ps1 +++ b/tools/Resolve-SelfHostedWindowsLanePlan.ps1 @@ -1,12 +1,12 @@ #Requires -Version 7.0 <# .SYNOPSIS - Resolves the availability plan for a self-hosted Windows LV32 lane. + Resolves the availability plan for a self-hosted Windows specialized lane. .DESCRIPTION Emits a deterministic planning artifact for a repository runner that must - advertise the required ingress plus LV32 capability labels. The helper uses - the repository runner inventory API or an injected inventory fixture. + advertise the required ingress plus optional capability labels. The helper + uses the repository runner inventory API or an injected inventory fixture. #> [CmdletBinding()] param( @@ -20,6 +20,15 @@ param( 'labview-2026', 'lv32' ), + [string]$ExecutionModel = 'self-hosted-windows-lv32', + [string]$RunnerImage = 'self-hosted-windows-lv32', + [string]$ExpectedContext = 'headless-labview-32', + [string]$ExpectedOs = 'windows', + [string[]]$RequiredHealthReceipts = @('labview-2026-host-plane-report'), + [string[]]$Notes = @( + 'Availability means an online, idle repository runner advertises every required label.', + 'The lane must skip rather than queue indefinitely when no matching runner is available.' + ), [string]$Token = $env:GITHUB_TOKEN, [string]$OutputJsonPath = 'tests/results/_agent/vi-history-dispatch/validate-vi-history-windows-lv32-plan.json', [string]$GitHubOutputPath = $env:GITHUB_OUTPUT, @@ -183,15 +192,12 @@ $summary = [ordered]@{ skipReason = '' failureClass = 'none' failureMessage = '' - executionModel = 'self-hosted-windows-lv32' - runnerImage = 'self-hosted-windows-lv32' - expectedContext = 'headless-labview-32' - expectedOs = 'windows' - requiredHealthReceipts = @('labview-2026-host-plane-report') - notes = @( - 'Availability means an online, idle repository runner advertises every required label.', - 'The lane must skip rather than queue indefinitely when no matching runner is available.' - ) + executionModel = [string]$ExecutionModel + runnerImage = [string]$RunnerImage + expectedContext = [string]$ExpectedContext + expectedOs = [string]$ExpectedOs + requiredHealthReceipts = @($RequiredHealthReceipts) + notes = @($Notes) } try { @@ -277,7 +283,7 @@ try { } else { $summary.available = $false $summary.status = 'missing-label' - $summary.skipReason = 'no online self-hosted Windows LV32 runner matched the required capability labels' + $summary.skipReason = 'no online self-hosted Windows runner matched the required capability labels' } } catch { $summary.available = $false @@ -315,7 +321,7 @@ if (-not [string]::IsNullOrWhiteSpace($StepSummaryPath)) { 'none' } $summaryLines = @( - '### Self-Hosted Windows LV32 Lane Plan', + '### Self-Hosted Windows Specialized Lane Plan', '', ('- status: `{0}`' -f [string]$summary.status), ('- available: `{0}`' -f ([string]([bool]$summary.available)).ToLowerInvariant()), diff --git a/tools/Test-WindowsNI2026q1HostPreflight.ps1 b/tools/Test-WindowsNI2026q1HostPreflight.ps1 index 6ff4f609f..2dbb3b83b 100644 --- a/tools/Test-WindowsNI2026q1HostPreflight.ps1 +++ b/tools/Test-WindowsNI2026q1HostPreflight.ps1 @@ -21,6 +21,8 @@ param( [string]$ResultsDir = 'tests/results/local-parity', [ValidateSet('desktop-local', 'github-hosted-windows')] [string]$ExecutionSurface = 'desktop-local', + [bool]$ManageDockerEngine = $false, + [bool]$AllowHostEngineMutation = $false, [switch]$AllowUnavailable, [string]$OutputJsonPath = '', [string]$GitHubOutputPath = $env:GITHUB_OUTPUT, @@ -292,7 +294,8 @@ try { $summary.contexts.final = [string]$desktopObservation.context $summary.contexts.finalOsType = [string]$desktopObservation.osType - if ([string]::Equals([string]$desktopObservation.osType, 'linux', [System.StringComparison]::OrdinalIgnoreCase)) { + $canMutateDesktopEngine = $ManageDockerEngine -and $AllowHostEngineMutation + if ([string]::Equals([string]$desktopObservation.osType, 'linux', [System.StringComparison]::OrdinalIgnoreCase) -and -not $canMutateDesktopEngine) { $summary.status = 'failure' $summary.failureClass = 'docker-engine-mismatch' $summary.failureMessage = ("desktop-local Windows NI preflight requires Docker Desktop Windows containers. Observed context '{0}' with OSType '{1}'. Switch Docker Desktop to Windows containers (`desktop-windows`) and retry." -f ([string]$desktopObservation.context ?? ''), [string]$desktopObservation.osType) @@ -354,8 +357,8 @@ try { -RuntimeProvider 'desktop' ` -ExpectedContext 'default' ` -AutoRepair:$true ` - -ManageDockerEngine:$false ` - -AllowHostEngineMutation:$false ` + -ManageDockerEngine:$ManageDockerEngine ` + -AllowHostEngineMutation:$AllowHostEngineMutation ` -SnapshotPath $snapshotPath ` -GitHubOutputPath '' if ($LASTEXITCODE -ne 0) { diff --git a/tools/policy/runner-capability-routing.json b/tools/policy/runner-capability-routing.json index c8bcce3e9..16ae1c607 100644 --- a/tools/policy/runner-capability-routing.json +++ b/tools/policy/runner-capability-routing.json @@ -119,6 +119,13 @@ { "workflow": ".github/workflows/validate.yml", "jobs": [ + { + "id": "vi-history-scenarios-windows", + "routingClass": "specialized-opt-in", + "requiredCapabilityLabels": [ + "docker-lane" + ] + }, { "id": "vi-history-scenarios-windows-lv32", "routingClass": "specialized-opt-in", diff --git a/tools/priority/__tests__/docker-labview-path-contract.test.mjs b/tools/priority/__tests__/docker-labview-path-contract.test.mjs index 42bcf9441..dcc0a93a5 100644 --- a/tools/priority/__tests__/docker-labview-path-contract.test.mjs +++ b/tools/priority/__tests__/docker-labview-path-contract.test.mjs @@ -11,7 +11,7 @@ function readRepoFile(relativePath) { return readFileSync(path.join(repoRoot, relativePath), 'utf8'); } -test('validate workflow pins explicit LabVIEW paths for hosted Linux and Windows VI history lanes', () => { +test('validate workflow pins explicit LabVIEW paths for hosted Linux and self-hosted Windows VI history lanes', () => { const workflow = readRepoFile('.github/workflows/validate.yml'); assert.match(workflow, /NI_LINUX_IMAGE:\s*nationalinstruments\/labview:2026q1-linux/); @@ -20,15 +20,19 @@ test('validate workflow pins explicit LabVIEW paths for hosted Linux and Windows assert.match(workflow, /-Image \$env:NI_LINUX_IMAGE/); assert.match(workflow, /-LabVIEWPath \$env:NI_LINUX_LABVIEW_PATH/); assert.match(workflow, /vi-history-scenarios-windows-plan:/); - assert.match(workflow, /Resolve-HostedWindowsLanePlan\.ps1/); + assert.match(workflow, /Resolve-SelfHostedWindowsLanePlan\.ps1/); assert.match(workflow, /vi-history-scenarios-windows:/); - assert.match(workflow, /runs-on:\s*windows-2022/); + assert.match(workflow, /runs-on:\s*\[self-hosted, Windows, X64, comparevi, capability-ingress, docker-lane\]/); assert.match(workflow, /NI_WINDOWS_IMAGE:\s*nationalinstruments\/labview:2026q1-windows/); assert.match(workflow, /NI_WINDOWS_LABVIEW_PATH:\s*C:\\Program Files\\National Instruments\\LabVIEW 2026\\LabVIEW\.exe/); assert.match(workflow, /Test-WindowsNI2026q1HostPreflight\.ps1/); assert.match(workflow, /Write-VIHistoryLaneEvidence\.ps1/); assert.match(workflow, /Run-NIWindowsContainerCompare\.ps1/); - assert.match(workflow, /-ExecutionSurface 'github-hosted-windows'/); + assert.match(workflow, /Assert-RunnerLabelContract\.ps1/); + assert.match(workflow, /-ExecutionSurface 'desktop-local'/); + assert.match(workflow, /-ManageDockerEngine:\$true/); + assert.match(workflow, /-AllowHostEngineMutation:\$true/); + assert.match(workflow, /Restore Docker Desktop context after Windows proof/); assert.match(workflow, /-Image \$env:NI_WINDOWS_IMAGE/); assert.match(workflow, /-LabVIEWPath \$env:NI_WINDOWS_LABVIEW_PATH/); assert.match(workflow, /validate-vi-history-scenarios-windows/); diff --git a/tools/priority/__tests__/runner-capability-routing-policy.test.mjs b/tools/priority/__tests__/runner-capability-routing-policy.test.mjs index 086c842e0..0ba88a41f 100644 --- a/tools/priority/__tests__/runner-capability-routing-policy.test.mjs +++ b/tools/priority/__tests__/runner-capability-routing-policy.test.mjs @@ -53,6 +53,10 @@ test('runner capability routing policy covers all current self-hosted compare wo assert.equal(job.routingClass, 'specialized-opt-in'); assert.deepEqual(job.requiredCapabilityLabels, ['labview-2026', 'lv32']); assert.deepEqual(job.requiredHealthReceipts, ['labview-2026-host-plane-report']); + } else if (entry.workflow === '.github/workflows/validate.yml' && job.id === 'vi-history-scenarios-windows') { + assert.equal(job.routingClass, 'specialized-opt-in'); + assert.deepEqual(job.requiredCapabilityLabels, ['docker-lane']); + assert.equal(job.requiredHealthReceipts, undefined); } else if (entry.workflow === '.github/workflows/validate.yml' && job.id === 'vi-history-scenarios-windows-lv32') { assert.equal(job.routingClass, 'specialized-opt-in'); assert.deepEqual(job.requiredCapabilityLabels, ['labview-2026', 'lv32']); diff --git a/tools/priority/__tests__/validate-vi-history-dispatch-contract.test.mjs b/tools/priority/__tests__/validate-vi-history-dispatch-contract.test.mjs index ebca3b181..d79d868d8 100644 --- a/tools/priority/__tests__/validate-vi-history-dispatch-contract.test.mjs +++ b/tools/priority/__tests__/validate-vi-history-dispatch-contract.test.mjs @@ -57,35 +57,41 @@ test('validate workflow Linux VI-history lane consumes shared dispatch-plan outp assert.doesNotMatch(linuxSection, /pull-requests: read/); }); -test('validate workflow Windows VI-history lane is gated by shared dispatch planning and portable hosted execution', () => { +test('validate workflow Windows VI-history lane is gated by shared dispatch planning and self-hosted docker execution', () => { const workflow = readRepoFile('.github/workflows/validate.yml'); const planSection = extractWorkflowJobSection(workflow, 'vi-history-scenarios-windows-plan', 'vi-history-scenarios-windows'); const windowsSection = extractWorkflowJobSection(workflow, 'vi-history-scenarios-windows', 'vi-history-scenarios-windows-lv32-plan'); assert.match(workflow, /vi-history-scenarios-windows-plan:\s*\r?\n\s+needs:\s*\[smoke-gate, lint, session-index, session-index-v2-contract, vi-history-scenarios-plan\]\r?\n\s+if:\s+needs\.smoke-gate\.outputs\.skip != 'true'/); assert.match(planSection, /permissions:\s*\r?\n\s+contents: read/); - assert.match(planSection, /Resolve portable hosted Windows lane/); - assert.match(planSection, /tools\/Resolve-HostedWindowsLanePlan\.ps1/); - assert.match(planSection, /-RunnerImage 'windows-2022'/); + assert.match(planSection, /Resolve self-hosted Windows Docker lane/); + assert.match(planSection, /tools\/Resolve-SelfHostedWindowsLanePlan\.ps1/); + assert.match(planSection, /-RequiredLabels \$requiredLabels/); + assert.match(planSection, /docker-lane/); assert.match(planSection, /outputs:\s*\r?\n\s+available:\s+\$\{\{\s*steps\.plan\.outputs\.available\s*\}\}/); assert.match(workflow, /vi-history-scenarios-windows:\s*\r?\n\s+needs:\s*\[smoke-gate, lint, session-index, session-index-v2-contract, vi-history-scenarios-plan, vi-history-scenarios-windows-plan\]\r?\n\s+if:\s+needs\.smoke-gate\.outputs\.skip != 'true' && needs\.vi-history-scenarios-plan\.outputs\.execute_lanes == 'true' && needs\.vi-history-scenarios-windows-plan\.outputs\.available == 'true'/); - assert.match(windowsSection, /runs-on:\s*windows-2022/); + assert.match(windowsSection, /runs-on:\s*\[self-hosted, Windows, X64, comparevi, capability-ingress, docker-lane\]/); + assert.match(windowsSection, /continue-on-error:\s*true/); assert.match(windowsSection, /Print VI history Windows runtime alignment/); - assert.match(windowsSection, /Collect hosted Windows runner health/); + assert.match(windowsSection, /Validate self-hosted runner label contract/); + assert.match(windowsSection, /Assert-RunnerLabelContract\.ps1/); + assert.match(windowsSection, /Collect self-hosted Windows runner health/); assert.match(windowsSection, /Collect-RunnerHealth\.ps1/); assert.match(windowsSection, /Project VI history Windows Docker-side evidence/); assert.match(windowsSection, /tools\/Write-VIHistoryLaneEvidence\.ps1/); assert.match(windowsSection, /Test-WindowsNI2026q1HostPreflight\.ps1/); - assert.match(windowsSection, /-ExecutionSurface 'github-hosted-windows'/); - assert.match(windowsSection, /-AllowUnavailable/); + assert.match(windowsSection, /-ExecutionSurface 'desktop-local'/); + assert.match(windowsSection, /-ManageDockerEngine:\$true/); + assert.match(windowsSection, /-AllowHostEngineMutation:\$true/); assert.match(windowsSection, /id:\s*windows-preflight/); assert.match(windowsSection, /Run-NIWindowsContainerCompare\.ps1/); assert.match(windowsSection, /if:\s*steps\.windows-preflight\.outputs\.windows_host_preflight_status == 'ready'/); + assert.match(windowsSection, /Restore Docker Desktop context after Windows proof/); + assert.match(windowsSection, /Invoke-DockerRuntimeManager\.ps1/); assert.match(windowsSection, /tests\/results\/local-parity\/windows\/_agent\/runner-health\.json/); assert.match(windowsSection, /ni-windows-container-stdout\.txt/); assert.match(windowsSection, /ni-windows-container-stderr\.txt/); - assert.doesNotMatch(windowsSection, /Assert-RunnerLabelContract\.ps1/); }); test('validate workflow self-hosted Windows LV32 VI-history lane is gated by inventory planning and headless proof receipts', () => { From afca493c14d51fe7f13e8fd99536c7cdfcb8598c Mon Sep 17 00:00:00 2001 From: Sergio Velderrain Date: Mon, 30 Mar 2026 21:53:01 -0700 Subject: [PATCH 12/16] Honor docker override across NI Linux proof tooling (#2066) Honor docker override in NI Linux proof path Co-authored-by: svelderrainruiz --- .../Assert-DockerRuntimeDeterminism.Tests.ps1 | 43 ++++++++++ tools/Assert-DockerRuntimeDeterminism.ps1 | 61 +++++++++++++-- tools/Run-NILinuxContainerCompare.ps1 | 78 ++++++++++++++++--- 3 files changed, 166 insertions(+), 16 deletions(-) diff --git a/tests/Assert-DockerRuntimeDeterminism.Tests.ps1 b/tests/Assert-DockerRuntimeDeterminism.Tests.ps1 index 9f7e708ba..498c949ff 100644 --- a/tests/Assert-DockerRuntimeDeterminism.Tests.ps1 +++ b/tests/Assert-DockerRuntimeDeterminism.Tests.ps1 @@ -171,6 +171,7 @@ exit 0 DOCKER_STUB_CONTEXT = $env:DOCKER_STUB_CONTEXT DOCKER_STUB_CONTEXT_SHOW_EMPTY = $env:DOCKER_STUB_CONTEXT_SHOW_EMPTY DOCKER_STUB_CONTEXT_USE_FAIL_TARGET = $env:DOCKER_STUB_CONTEXT_USE_FAIL_TARGET + DOCKER_COMMAND_OVERRIDE = $env:DOCKER_COMMAND_OVERRIDE DOCKER_HOST = $env:DOCKER_HOST } } @@ -231,6 +232,48 @@ exit 0 $snapshot.result.reason | Should -Match 'exitCode=1' } + It 'honors DOCKER_COMMAND_OVERRIDE before a failing docker on PATH' { + $work = Join-Path $TestDrive 'docker-command-override' + New-Item -ItemType Directory -Path $work -Force | Out-Null + & $script:CreateDockerWslStubs -WorkRoot $work + + $fallbackBin = Join-Path $work 'fallback-bin' + New-Item -ItemType Directory -Path $fallbackBin -Force | Out-Null + if ($IsWindows) { + @" +@echo off +echo fallback docker should not run 1>&2 +exit /b 9 +"@ | Set-Content -LiteralPath (Join-Path $fallbackBin 'docker.cmd') -Encoding ascii + $env:PATH = "{0};{1}" -f $fallbackBin, $env:PATH + } else { + @' +#!/usr/bin/env bash +echo fallback docker should not run 1>&2 +exit 9 +'@ | Set-Content -LiteralPath (Join-Path $fallbackBin 'docker') -Encoding utf8 + & chmod +x (Join-Path $fallbackBin 'docker') + $env:PATH = "{0}:{1}" -f $fallbackBin, $env:PATH + } + + Set-Item Env:DOCKER_COMMAND_OVERRIDE (Join-Path $work 'bin' 'docker.ps1') + Set-Item Env:DOCKER_STUB_INFO_MODE 'parsed-linux' + Set-Item Env:DOCKER_STUB_CONTEXT 'desktop-linux' + + $snapshotPath = Join-Path $work 'runtime.json' + $output = & pwsh -NoLogo -NoProfile -File $script:GuardScript ` + -ExpectedOsType linux ` + -ExpectedContext desktop-linux ` + -AutoRepair:$false ` + -SnapshotPath $snapshotPath ` + -GitHubOutputPath '' 2>&1 + $LASTEXITCODE | Should -Be 0 -Because ($output -join "`n") + + $snapshot = Get-Content -LiteralPath $snapshotPath -Raw | ConvertFrom-Json -Depth 12 + $snapshot.result.status | Should -Be 'ok' + $snapshot.observed.osType | Should -Be 'linux' + } + It 'classifies parse-defect when docker info output is non-empty but unparseable' { $work = Join-Path $TestDrive 'parse-defect-unparseable' New-Item -ItemType Directory -Path $work -Force | Out-Null diff --git a/tools/Assert-DockerRuntimeDeterminism.ps1 b/tools/Assert-DockerRuntimeDeterminism.ps1 index c516284d9..5de4003c7 100644 --- a/tools/Assert-DockerRuntimeDeterminism.ps1 +++ b/tools/Assert-DockerRuntimeDeterminism.ps1 @@ -113,6 +113,38 @@ function Split-OutputLines { return @($Text -split "(`r`n|`n|`r)" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) } +function Resolve-DockerCommandSource { + $override = $env:DOCKER_COMMAND_OVERRIDE + if (-not [string]::IsNullOrWhiteSpace($override) -and (Test-Path -LiteralPath $override -PathType Leaf)) { + return [System.IO.Path]::GetFullPath($override) + } + + $pathSeparator = [System.IO.Path]::PathSeparator + $pathEntries = @($env:PATH -split [regex]::Escape([string]$pathSeparator)) + $candidates = if ($IsWindows) { + @('docker.exe', 'docker.cmd', 'docker.ps1', 'docker.bat', 'docker') + } else { + @('docker', 'docker.sh', 'docker.exe', 'docker.ps1', 'docker.cmd') + } + + foreach ($entry in $pathEntries) { + if ([string]::IsNullOrWhiteSpace($entry)) { continue } + foreach ($name in $candidates) { + $candidatePath = Join-Path $entry $name + if (Test-Path -LiteralPath $candidatePath -PathType Leaf) { + return [System.IO.Path]::GetFullPath($candidatePath) + } + } + } + + $command = Get-Command -Name 'docker' -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($null -eq $command -or [string]::IsNullOrWhiteSpace([string]$command.Source)) { + return $null + } + + return [System.IO.Path]::GetFullPath([string]$command.Source) +} + function Invoke-ProcessWithTimeout { [CmdletBinding()] param( @@ -123,16 +155,31 @@ function Invoke-ProcessWithTimeout { $safeTimeout = [math]::Max(5, [int]$TimeoutSeconds) $resolvedFilePath = $FilePath + $effectiveArguments = @($Arguments) + if ([string]::Equals($FilePath, 'docker', [System.StringComparison]::OrdinalIgnoreCase)) { + $dockerCommandSource = Resolve-DockerCommandSource + if (-not [string]::IsNullOrWhiteSpace($dockerCommandSource)) { + $dockerCommandExtension = [System.IO.Path]::GetExtension($dockerCommandSource) + if ([System.StringComparer]::OrdinalIgnoreCase.Equals($dockerCommandExtension, '.ps1')) { + $resolvedFilePath = (Get-Command -Name 'pwsh' -ErrorAction Stop | Select-Object -First 1).Source + $effectiveArguments = @('-NoLogo', '-NoProfile', '-File', $dockerCommandSource) + @($Arguments) + } else { + $resolvedFilePath = $dockerCommandSource + } + } + } try { - $resolvedCommand = Get-Command -Name $FilePath -CommandType Application -ErrorAction Stop | Select-Object -First 1 - if ($resolvedCommand -and $resolvedCommand.Source) { - $resolvedFilePath = [string]$resolvedCommand.Source - } elseif ($resolvedCommand -and $resolvedCommand.Path) { - $resolvedFilePath = [string]$resolvedCommand.Path + if ([string]::Equals($resolvedFilePath, $FilePath, [System.StringComparison]::Ordinal)) { + $resolvedCommand = Get-Command -Name $FilePath -CommandType Application -ErrorAction Stop | Select-Object -First 1 + if ($resolvedCommand -and $resolvedCommand.Source) { + $resolvedFilePath = [string]$resolvedCommand.Source + } elseif ($resolvedCommand -and $resolvedCommand.Path) { + $resolvedFilePath = [string]$resolvedCommand.Path + } } } catch {} - $argText = if ($Arguments -and $Arguments.Count -gt 0) { [string]::Join(' ', $Arguments) } else { '' } + $argText = if ($effectiveArguments -and $effectiveArguments.Count -gt 0) { [string]::Join(' ', $effectiveArguments) } else { '' } $commandText = if ([string]::IsNullOrWhiteSpace($argText)) { $resolvedFilePath } else { "$resolvedFilePath $argText" } $result = [ordered]@{ @@ -150,7 +197,7 @@ function Invoke-ProcessWithTimeout { $psi.RedirectStandardOutput = $true $psi.RedirectStandardError = $true $psi.CreateNoWindow = $true - foreach ($arg in @($Arguments)) { + foreach ($arg in @($effectiveArguments)) { [void]$psi.ArgumentList.Add([string]$arg) } diff --git a/tools/Run-NILinuxContainerCompare.ps1 b/tools/Run-NILinuxContainerCompare.ps1 index 3b2752836..3735b49c8 100644 --- a/tools/Run-NILinuxContainerCompare.ps1 +++ b/tools/Run-NILinuxContainerCompare.ps1 @@ -155,7 +155,7 @@ function Resolve-DockerCommandSource { $candidates = if ($IsWindows) { @('docker.exe', 'docker.cmd', 'docker.ps1', 'docker.bat', 'docker') } else { - @('docker', 'docker.sh') + @('docker', 'docker.sh', 'docker.exe', 'docker.ps1', 'docker.cmd') } foreach ($entry in $pathEntries) { if ([string]::IsNullOrWhiteSpace($entry)) { continue } @@ -173,6 +173,24 @@ function Resolve-DockerCommandSource { return [string]$command.Source } +function Invoke-DirectDockerCommand { + param([Parameter(Mandatory)][string[]]$DockerArgs) + + $dockerCommandSource = Resolve-DockerCommandSource + $dockerCommandExtension = [System.IO.Path]::GetExtension($dockerCommandSource) + $stdout = @() + if ([System.StringComparer]::OrdinalIgnoreCase.Equals($dockerCommandExtension, '.ps1')) { + $stdout = @(& pwsh -NoLogo -NoProfile -File $dockerCommandSource @DockerArgs) + } else { + $stdout = @(& $dockerCommandSource @DockerArgs) + } + + return [pscustomobject]@{ + ExitCode = [int]$LASTEXITCODE + StdOut = @($stdout) + } +} + function Get-EffectiveCompareFlags { param( [AllowNull()][string[]]$InputFlags @@ -377,8 +395,8 @@ function Resolve-OutputReportPath { function Test-DockerImageExists { param([Parameter(Mandatory)][string]$Tag) - & docker image inspect $Tag *> $null - return ($LASTEXITCODE -eq 0) + $inspectResult = Invoke-DirectDockerCommand -DockerArgs @('image', 'inspect', $Tag) 2>$null + return ($inspectResult.ExitCode -eq 0) } function Get-OrAddMountPath { @@ -405,6 +423,44 @@ function Convert-HostFileToContainerPath { return (Join-Path $containerDir (Split-Path -Leaf $HostFilePath)).Replace('\', '/') } +function Resolve-DockerBindMountHostPath { + param( + [Parameter(Mandatory)][string]$HostPath, + [AllowEmptyString()][string]$DockerCommandSource + ) + + $resolvedHostPath = [System.IO.Path]::GetFullPath($HostPath) + if ([string]::IsNullOrWhiteSpace($DockerCommandSource)) { + return $resolvedHostPath + } + + $dockerCommandExtension = [System.IO.Path]::GetExtension($DockerCommandSource) + if (-not [System.StringComparer]::OrdinalIgnoreCase.Equals($dockerCommandExtension, '.exe')) { + return $resolvedHostPath + } + + if (-not $resolvedHostPath.StartsWith('/')) { + return $resolvedHostPath + } + + $wslCommand = Get-Command -Name 'wsl.exe' -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($null -eq $wslCommand -or [string]::IsNullOrWhiteSpace([string]$wslCommand.Source)) { + return $resolvedHostPath + } + + $translatedOutput = & $wslCommand.Source wslpath -w $resolvedHostPath 2>$null + if ($LASTEXITCODE -ne 0) { + return $resolvedHostPath + } + + $translatedPath = [string](@($translatedOutput) | Select-Object -Last 1) + if ([string]::IsNullOrWhiteSpace($translatedPath)) { + return $resolvedHostPath + } + + return $translatedPath.Trim() +} + function Test-HostPathWithinRoot { param( [Parameter(Mandatory)][string]$RootPath, @@ -460,10 +516,11 @@ function Convert-HostPathToExistingContainerPath { function Get-DockerContainerRecord { param([Parameter(Mandatory)][string]$ContainerName) - $inspectOutput = & docker inspect $ContainerName 2>$null - if ($LASTEXITCODE -ne 0) { + $inspectResult = Invoke-DirectDockerCommand -DockerArgs @('inspect', $ContainerName) 2>$null + if ($inspectResult.ExitCode -ne 0) { return $null } + $inspectOutput = @($inspectResult.StdOut) try { $records = $inspectOutput | ConvertFrom-Json -Depth 12 -ErrorAction Stop @@ -1739,8 +1796,8 @@ function Invoke-DockerExecWithTimeout { while (-not $process.HasExited) { if ((Get-Date) -ge $deadline) { try { Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue } catch {} - try { & docker exec $ContainerName sh -lc 'pkill -f LabVIEWCLI || true' *> $null } catch {} - try { & docker stop --time 1 $ContainerName *> $null } catch {} + try { Invoke-DirectDockerCommand -DockerArgs @('exec', $ContainerName, 'sh', '-lc', 'pkill -f LabVIEWCLI || true') *> $null } catch {} + try { Invoke-DirectDockerCommand -DockerArgs @('stop', '--time', '1', $ContainerName) *> $null } catch {} $capturedOutput = Complete-DockerProcessInvocation -Invocation $invocation return [pscustomobject]@{ TimedOut = $true @@ -2467,6 +2524,7 @@ try { $containerBaseVi = '' $containerHeadVi = '' $containerReportPath = '' + $dockerCommandSource = Resolve-DockerCommandSource if ($useExistingContainer) { $reuseRepoHostPathResolved = if ([string]::IsNullOrWhiteSpace($ReuseRepoHostPath)) { if ($viHistoryEnabled -and -not [string]::IsNullOrWhiteSpace([string]$resolvedRuntimeBootstrap.viHistory.repoHostPath)) { @@ -2661,11 +2719,13 @@ try { } if (-not $useExistingContainer) { foreach ($entry in ($mounts.GetEnumerator() | Sort-Object Name)) { - $volumeSpec = '{0}:{1}' -f $entry.Name, $entry.Value + $mountHostPath = Resolve-DockerBindMountHostPath -HostPath ([string]$entry.Name) -DockerCommandSource $dockerCommandSource + $volumeSpec = '{0}:{1}' -f $mountHostPath, $entry.Value $dockerArgs += @('-v', $volumeSpec) } foreach ($runtimeMount in $resolvedRuntimeInjectionMounts) { - $dockerArgs += @('-v', ('{0}:{1}' -f $runtimeMount.hostPath, $runtimeMount.containerPath)) + $runtimeMountHostPath = Resolve-DockerBindMountHostPath -HostPath ([string]$runtimeMount.hostPath) -DockerCommandSource $dockerCommandSource + $dockerArgs += @('-v', ('{0}:{1}' -f $runtimeMountHostPath, $runtimeMount.containerPath)) } } if (-not [string]::IsNullOrWhiteSpace($containerBaseVi)) { From 1639d485c3004634069c22650fd5ee4a27682af4 Mon Sep 17 00:00:00 2001 From: svelderrainruiz Date: Mon, 30 Mar 2026 22:02:02 -0700 Subject: [PATCH 13/16] chore(release): prepare v0.6.10 --- Directory.Build.props | 6 +++--- package-lock.json | 4 ++-- package.json | 2 +- tools/CompareVI.Tools/CompareVI.Tools.psd1 | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index eb5b0923f..6529d87ce 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -7,9 +7,9 @@ false true true - 0.6.9 - 0.6.9.0 - 0.6.9.0 + 0.6.10 + 0.6.10.0 + 0.6.10.0 $(Version)+local package-first project diff --git a/package-lock.json b/package-lock.json index cceb48561..c752efe8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "compare-vi-cli-action", - "version": "0.6.9", + "version": "0.6.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "compare-vi-cli-action", - "version": "0.6.9", + "version": "0.6.10", "license": "BSD-3-Clause", "dependencies": { "argparse": "^2.0.1", diff --git a/package.json b/package.json index 42d318920..31861bc97 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "compare-vi-cli-action", "private": true, - "version": "0.6.9", + "version": "0.6.10", "license": "BSD-3-Clause", "type": "module", "scripts": { diff --git a/tools/CompareVI.Tools/CompareVI.Tools.psd1 b/tools/CompareVI.Tools/CompareVI.Tools.psd1 index 39a3ba310..4dfcc78a5 100644 --- a/tools/CompareVI.Tools/CompareVI.Tools.psd1 +++ b/tools/CompareVI.Tools/CompareVI.Tools.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'CompareVI.Tools.psm1' - ModuleVersion = '0.6.9' + ModuleVersion = '0.6.10' GUID = '1f9b5f7f-1ab6-4db9-8e36-6b7a6d5e9c8f' Author = 'LabVIEW Community CI' CompanyName = 'LabVIEW Community' From 12779e09babe9cafc03983257eab1c01a67e0b2a Mon Sep 17 00:00:00 2001 From: svelderrainruiz Date: Mon, 30 Mar 2026 22:06:15 -0700 Subject: [PATCH 14/16] docs(release): prepare v0.6.10 maintenance packet --- CHANGELOG.md | 33 ++++++ .../archive/releases/RELEASE_NOTES_v0.6.10.md | 62 ++++++++++ docs/release/PR_NOTES.md | 109 ++++++++++-------- docs/release/TAG_PREP_CHECKLIST.md | 74 +++++++----- 4 files changed, 199 insertions(+), 79 deletions(-) create mode 100644 docs/archive/releases/RELEASE_NOTES_v0.6.10.md diff --git a/CHANGELOG.md b/CHANGELOG.md index eece0d86d..84b9a6ad2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,39 @@ The format is based on Keep a Changelog, and this project adheres to Semantic Ve ## [Unreleased] +## [v0.6.10] - 2026-03-30 + +### Changed + +- `Compare-VIHistory.ps1`, `Render-VIHistoryReport.ps1`, and the supporting + category helpers now preserve touch-history chronology, keep collapsed noise + pairs visible, canonicalize history labels, and emit review-order guidance so + the canonical VI-history proof answers which pair to inspect first instead of + flattening the newest meaningful change into report noise. +- `validate.yml` now treats Windows VI-history proofing as a self-hosted split: + `vi-history-scenarios-windows` runs the 64-bit Windows Docker proof on the + ingress host, while `vi-history-scenarios-windows-lv32` runs in parallel as + the native 32-bit reference lane. +- The NI Linux proof tooling and local `comparevi-history` fast loop now honor + `DOCKER_COMMAND_OVERRIDE`, making WSL-hosted `docker.exe` and wrapper-based + runtimes behave consistently during canonical proof replay. + +### Added + +- Chronology-aware decision guidance in the VI-history report facade, + including newest-pair status, explicit review sequence, and focus/context + bucket summaries for the canonical proof target. +- Workflow-planner coverage for the self-hosted Windows Docker split, + including regression checks for the empty health-receipt binding path. +- Local fast-loop regression coverage proving the released facade can pre-pull + the proof image through a docker override path before the hosted proof runs. + +### Documentation + +- Updated the release helper packet for the `v0.6.10` maintenance cut and + shifted the canonical product proof references from `DrawIcon.vi` to + `Tooling/deployment/VIP_Pre-Install Custom Action.vi`. + ## [v0.6.9] - 2026-03-30 ### Fixed diff --git a/docs/archive/releases/RELEASE_NOTES_v0.6.10.md b/docs/archive/releases/RELEASE_NOTES_v0.6.10.md new file mode 100644 index 000000000..b5fd91903 --- /dev/null +++ b/docs/archive/releases/RELEASE_NOTES_v0.6.10.md @@ -0,0 +1,62 @@ +# Release Notes v0.6.10 + +`v0.6.10` is the first stable cut that carries the post-`v0.6.9` VI-history +product work: chronology-aware decision guidance, the self-hosted Windows proof +split, and the Docker-override/runtime fixes that keep the canonical proof +reproducible on this machine. + +## Highlights + +- The VI-history report now preserves touch-history chronology and tells the + developer which pair to inspect first instead of hiding the newest meaningful + change behind collapsed report noise. +- Windows proofing now uses the intended split architecture: + `vi-history-scenarios-windows` proves the 64-bit Windows Docker lane on the + ingress host, while `vi-history-scenarios-windows-lv32` runs in parallel as + the native 32-bit reference. +- The NI Linux proof tooling and local fast loop now honor + `DOCKER_COMMAND_OVERRIDE`, keeping WSL-hosted `docker.exe` and wrapper-based + runtimes deterministic during canonical proof replay. +- This cut supersedes the unpublished `v0.6.9` draft. It is the first stable + release that combines the bundle-contract recovery work with the merged + product-semantic history improvements. + +## Included maintenance slice + +- `#2056` feat: use touch-history semantics in VI history proofs +- `#2057` fix: preserve VI history category specificity +- `#2058` feat: reveal collapsed VI history pairs +- `#2059` docs: clarify VI history decision guidance +- `#2060` fix: make LV32 shadow proof non-blocking +- `#2061` feat: identify latest VI history signal pair +- `#2062` feat: add VI history review sequence guidance +- `#2063` feat: expose VI history decision chronology +- `#2065` feat: run Windows VI history proof on self-hosted ingress +- `#2066` fix: honor docker override across NI Linux proof tooling + +## Validation highlights + +- Release branch `release/v0.6.10` updates the stable backend version surfaces + to `0.6.10`. +- The merged-state canonical proof succeeded for: + - target: + `Tooling/deployment/VIP_Pre-Install Custom Action.vi` + - outcome: + 3 comparisons, 2 signal diffs, 1 collapsed noise + - decision statement: + newest VI touch is metadata-only; review starts at pair 2 for the newest + meaningful functional change +- The self-hosted Windows planner contract was re-proved after the split: + - the 64-bit Docker lane resolves through the self-hosted ingress inventory + - the LV32 lane resolves in parallel as the native reference + - planner regressions for the empty health-receipt binding path now fail + closed in focused tests + +## Consumer impact + +- Stable consumers should move from `@v0.6.8` to `@v0.6.10`. +- `comparevi-history` should treat `v0.6.10` as the minimum backend ref for the + canonical `VIP_Pre-Install Custom Action.vi` proof going forward. +- The GitLab benchmark should only refresh after the canonical proof is rerun on + the published `v0.6.10` backend ref, not on the merged-state maintainer + override. diff --git a/docs/release/PR_NOTES.md b/docs/release/PR_NOTES.md index 5f40607bd..a8495ff08 100644 --- a/docs/release/PR_NOTES.md +++ b/docs/release/PR_NOTES.md @@ -1,41 +1,44 @@ -# Release v0.6.9 - PR Notes Helper +# Release v0.6.10 - PR Notes Helper -Reference sheet for the `v0.6.9` maintenance release. This cut repairs the -published `CompareVI.Tools` bundle so released downstream consumers can execute -the hosted NI Linux VI-history contract without a maintainer checkout. +Reference sheet for the `v0.6.10` maintenance release. This cut carries the +merged VI-history product work after the stale `v0.6.9` draft: decision-useful +history guidance, the self-hosted Windows proof split, and the Docker-override +runtime fixes that make the canonical proof reproducible on this machine. ## 1. Summary -Release `v0.6.9` focuses on three themes: +Release `v0.6.10` focuses on four themes: -- **Executable released bundle**: `CompareVI.Tools-v0.6.9.zip` now carries the - hosted NI Linux helper `tools/Get-LabVIEWContainerShellContract.ps1` instead - of shipping an incomplete runtime contract. -- **Faster local replay**: maintainers can now mirror - `vi-history-scenarios-windows` through the checked-in Windows Docker replay - lane instead of waiting on the hosted proof for every iteration. -- **Released-backend recovery**: downstream VI-history consumers can once again - rely on the published bundle rather than a maintainer override or local - source tree to execute the canonical proof path. -- **Narrow maintenance scope**: this cut only repairs released bundle - executability and local maintainer replay. It does not expand the public - VI-history surface or add new product claims. +- **Decision-useful history output**: the canonical VI-history report now + preserves touch-history chronology, surfaces collapsed-noise pairs, and tells + the developer which pair to review first. +- **Windows proof split**: `vi-history-scenarios-windows` now runs as the + self-hosted Windows Docker 64-bit lane, while + `vi-history-scenarios-windows-lv32` runs in parallel as the native 32-bit + reference. +- **Docker override determinism**: the NI Linux proof tooling and local fast + loop now honor `DOCKER_COMMAND_OVERRIDE`, keeping WSL-hosted `docker.exe` + and wrapper-based runtimes deterministic. +- **Released-backend proof target**: `comparevi-history` can now re-pin to the + released backend and rerun the canonical + `VIP_Pre-Install Custom Action.vi` proof without a maintainer-only runtime + path. ## 2. Maintenance Highlights -- `tools/Publish-CompareVIToolsArtifact.ps1` now places - `tools/Get-LabVIEWContainerShellContract.ps1` in the bundle and advertises it - through the published hosted-runner contract metadata. -- `tools/Test-CompareVIHistoryBundleCertification.ps1` now fails closed if the - extracted bundle omits the hosted NI Linux support scripts required by the - released consumer path. -- `tools/priority/windows-workflow-replay-lane.mjs` now supports a local - `vi-history-scenarios-windows` replay mode so hosted Windows scenario fixes - can be iterated on this machine without changing the hosted certification - contract. -- Stable release surfaces now pin `0.6.9`, isolating the bundle-contract repair - in an immutable stable cut after the broken `v0.6.8` publication. +- `tools/Compare-VIHistory.ps1`, `tools/Render-VIHistoryReport.ps1`, and + `tools/VICategoryBuckets.psm1` now keep touch-history pairs visible and + emit chronology-aware decision guidance instead of hiding the newest + meaningful change behind report noise. +- `validate.yml` now routes Windows VI-history proofing through the self-hosted + ingress host: Docker-backed 64-bit proof plus parallel LV32 reference. +- `tools/Run-NILinuxContainerCompare.ps1`, + `tools/Assert-DockerRuntimeDeterminism.ps1`, and the local fast-loop facade + now honor `DOCKER_COMMAND_OVERRIDE`, keeping the canonical proof runnable + under WSL-hosted `docker.exe`. +- Stable release surfaces now pin `0.6.10`, superseding the unpublished + `v0.6.9` draft with the merged product-semantic work. ## 3. Validation Snapshot @@ -45,17 +48,21 @@ Release `v0.6.9` focuses on three themes: - `smoke-gate` - `Policy Guard (Upstream) / policy-guard` - `commit-integrity` -- [x] Direct bundle proof restored the published hosted NI Linux contract: - - extracted bundle contains both `tools/Run-NILinuxContainerCompare.ps1` and - `tools/Get-LabVIEWContainerShellContract.ps1` - - bundle certification summary reports `status: producer-native-ready` -- [ ] `node tools/npm/run-script.mjs release:finalize -- 0.6.9` completes from +- [x] Direct merged-state proof succeeded for the canonical VI target: + - target: `Tooling/deployment/VIP_Pre-Install Custom Action.vi` + - outcome: + 3 comparisons, 2 signal diffs, 1 collapsed noise + - decision statement: + newest VI touch is metadata-only; review starts at pair 2 for the newest + meaningful functional change +- [ ] `node tools/npm/run-script.mjs release:finalize -- 0.6.10` completes from a clean helper lane and writes fresh finalize metadata under `tests/results/_agent/release/` -- [ ] Published release `v0.6.9` includes the signed distribution assets, +- [ ] Published release `v0.6.10` includes the signed distribution assets, `SHA256SUMS.txt`, `sbom.spdx.json`, and `provenance.json` -- [ ] `comparevi-history` repins `comparevi-backend-ref.txt` to `v0.6.9` - before the canonical `DrawIcon.vi` product proof is rerun +- [ ] `comparevi-history` repins `comparevi-backend-ref.txt` to `v0.6.10` + before the canonical `VIP_Pre-Install Custom Action.vi` product proof is + rerun on the released backend ## 4. Reviewer Focus @@ -63,22 +70,30 @@ Release `v0.6.9` focuses on three themes: - `package.json` - `Directory.Build.props` - `tools/CompareVI.Tools/CompareVI.Tools.psd1` -- Review the released bundle contract repair for correctness: - - `tools/Publish-CompareVIToolsArtifact.ps1` - - `tools/Test-CompareVIHistoryBundleCertification.ps1` - - `docs/schemas/comparevi-history-bundle-certification-v1.schema.json` +- Review the decision-useful VI-history surface for correctness: + - `tools/Compare-VIHistory.ps1` + - `tools/Render-VIHistoryReport.ps1` + - `tools/VICategoryBuckets.psm1` + - `tests/CompareVI.History.Tests.ps1` + - `tests/Render-VIHistoryReport.Tests.ps1` +- Review the Windows proof split and Docker override behavior: + - `.github/workflows/validate.yml` + - `tools/Run-NILinuxContainerCompare.ps1` + - `tools/Assert-DockerRuntimeDeterminism.ps1` - Review the release helper packet for consistency: - `CHANGELOG.md` - `docs/release/TAG_PREP_CHECKLIST.md` - - `docs/archive/releases/RELEASE_NOTES_v0.6.9.md` + - `docs/archive/releases/RELEASE_NOTES_v0.6.10.md` ## 5. Follow-Up After Stable -1. Re-pin `comparevi-history` from `v0.6.8` to `v0.6.9` and rerun the - canonical `DrawIcon.vi` proof on the released backend. -2. Re-evaluate the current emitted history surface against the real developer +1. Re-pin `comparevi-history` from `v0.6.8` to `v0.6.10` and rerun the + canonical `VIP_Pre-Install Custom Action.vi` proof on the released backend. +2. Supersede the stale `v0.6.9` draft release so `v0.6.10` is the only active + unpublished stable cut during final publication. +3. Re-evaluate the current emitted history surface against the real developer question before treating any mode as decision-ready. -3. Reduce the public mode surface again if the rerun product proof only +4. Reduce the public mode surface again if the rerun product proof only justifies a narrower mode set. ---- Updated: 2026-03-30 (prepared for the `v0.6.9` maintenance cut). +--- Updated: 2026-03-30 (prepared for the `v0.6.10` maintenance cut). diff --git a/docs/release/TAG_PREP_CHECKLIST.md b/docs/release/TAG_PREP_CHECKLIST.md index eed74fb64..cfcc38386 100644 --- a/docs/release/TAG_PREP_CHECKLIST.md +++ b/docs/release/TAG_PREP_CHECKLIST.md @@ -1,14 +1,14 @@ -# v0.6.9 Tag Preparation Checklist +# v0.6.10 Tag Preparation Checklist -Helper reference for cutting or replaying the `v0.6.9` maintenance release. +Helper reference for cutting or replaying the `v0.6.10` maintenance release. Aligns with the archived release notes -(`../archive/releases/RELEASE_NOTES_v0.6.9.md`) and the checked-in stable +(`../archive/releases/RELEASE_NOTES_v0.6.10.md`) and the checked-in stable release surfaces. ## 1. Pre-flight Verification -- [ ] Work from `release/v0.6.9` and ensure it contains the final maintenance +- [ ] Work from `release/v0.6.10` and ensure it contains the final maintenance changes. - [ ] CI is green on the release branch (`lint`, `pester / normalize`, `smoke-gate`, `Policy Guard (Upstream) / policy-guard`, @@ -22,30 +22,34 @@ release surfaces. ## 2. Version & Metadata Consistency - [ ] `CHANGELOG.md` contains a finalized - `## [v0.6.9] - 2026-03-30` section. -- [ ] Stable docs reference `v0.6.8` consistently until `v0.6.9` publication - completes, and the release helper packet references `v0.6.9` + `## [v0.6.10] - 2026-03-30` section. +- [ ] Stable docs reference `v0.6.8` consistently until `v0.6.10` publication + completes, and the release helper packet references `v0.6.10` consistently. - [ ] `package.json`, `Directory.Build.props`, and - `tools/CompareVI.Tools/CompareVI.Tools.psd1` all report `0.6.9`. + `tools/CompareVI.Tools/CompareVI.Tools.psd1` all report `0.6.10`. - [ ] `docs/action-outputs.md` still matches `action.yml`. - [ ] Update `docs/documentation-manifest.json` if release-doc coverage changed. -## 3. Bundle Contract Regression Validation +## 3. Canonical Proof & Runtime Validation -- [ ] Focused bundle regression tests pass locally: +- [ ] Focused canonical proof replay succeeds on the release branch: ```bash -pwsh -NoLogo -NoProfile -File tools/Test-CompareVIHistoryBundleCertification.ps1 -BundleArchivePath tests/results/_agent/bundle-fix/artifacts/CompareVI.Tools-v0.6.9.zip -ResultsDir tests/results/_agent/bundle-fix/certification -SummaryJsonPath tests/results/_agent/bundle-fix/certification/summary.json +DOCKER_COMMAND_OVERRIDE="$(command -v docker.exe)" pwsh -NoLogo -NoProfile -File /scripts/Invoke-CompareVIHistoryManualExplorationFastLoop.ps1 -ConsumerRepositoryRoot -ConsumerRef develop -ViPath 'Tooling/deployment/VIP_Pre-Install Custom Action.vi' -ToolingRoot -InvokeScriptPath -ResultsDir -NoisePolicy collapse ``` -- [ ] Confirm the published bundle preserves the hosted NI Linux consumer contract: - -- [ ] The extracted bundle contains: - `tools/Run-NILinuxContainerCompare.ps1` - and - `tools/Get-LabVIEWContainerShellContract.ps1` -- [ ] Bundle certification reports `status: producer-native-ready` +- [ ] Confirm the merged-state proof remains decision-useful: + - 3 comparisons + - 2 signal diffs + - 1 collapsed noise + - decision statement routes review to pair 2 as the newest meaningful change +- [ ] Confirm the self-hosted Windows split remains healthy: + - `vi-history-scenarios-windows` plans and executes as the 64-bit Windows + Docker lane + - `vi-history-scenarios-windows-lv32` plans in parallel as the native + 32-bit reference + - planner regressions for the empty health-receipt binding path remain green - [ ] `comparevi-history` pin-bump coordination is queued immediately after publication so the canonical product proof uses the released backend instead of a maintainer override. @@ -53,9 +57,11 @@ pwsh -NoLogo -NoProfile -File tools/Test-CompareVIHistoryBundleCertification.ps1 ## 4. Release Materials Review - [ ] `PR_NOTES.md`, this checklist, and - `../archive/releases/RELEASE_NOTES_v0.6.9.md` are consistent. + `../archive/releases/RELEASE_NOTES_v0.6.10.md` are consistent. - [ ] `README.md` and `docs/USAGE_GUIDE.md` still treat `v0.6.8` as the - previously released stable pin until `v0.6.9` publication completes. + previously released stable pin until `v0.6.10` publication completes. +- [ ] The release packet consistently calls the canonical proof target + `Tooling/deployment/VIP_Pre-Install Custom Action.vi`. ## 5. Tag Creation @@ -63,7 +69,7 @@ pwsh -NoLogo -NoProfile -File tools/Test-CompareVIHistoryBundleCertification.ps1 ```pwsh node tools/npm/run-script.mjs priority:release:signing:readiness -node tools/npm/run-script.mjs priority:release:conductor -- --apply --channel stable --version 0.6.9 +node tools/npm/run-script.mjs priority:release:conductor -- --apply --channel stable --version 0.6.10 ``` - [ ] Confirm `tests/results/_agent/release/release-signing-readiness.json` @@ -74,34 +80,38 @@ node tools/npm/run-script.mjs priority:release:conductor -- --apply --channel st - [ ] Create an annotated stable tag: ```pwsh -git tag -a v0.6.9 -m "v0.6.9: repair CompareVI.Tools hosted bundle contract" +git tag -a v0.6.10 -m "v0.6.10: ship decision-useful VI history proofing" ``` - [ ] Push the tag: ```pwsh -git push origin v0.6.9 +git push origin v0.6.10 ``` ## 6. Validation After Publish -- [ ] Run `node tools/npm/run-script.mjs release:finalize -- 0.6.9` from a +- [ ] Run `node tools/npm/run-script.mjs release:finalize -- 0.6.10` from a clean helper lane to fast-forward `main` and `develop`, then record the finalize metadata. -- [ ] Install the bundle via `@v0.6.9` in a sample workflow and confirm the +- [ ] Install the bundle via `@v0.6.10` in a sample workflow and confirm the released hosted NI Linux VI-history contract executes without a local source-tree override. - [ ] Optional maintainer fast loop: run the local Windows Docker replay lane for `vi-history-scenarios-windows` and confirm it still mirrors the - hosted contract without replacing the hosted proof requirement. -- [ ] Re-pin `comparevi-history` to `v0.6.9` and confirm the canonical - `DrawIcon.vi` proof reaches real comparisons on the released backend. + self-hosted certification contract without replacing the release proof. +- [ ] Re-pin `comparevi-history` to `v0.6.10` and confirm the canonical + `VIP_Pre-Install Custom Action.vi` proof reaches real comparisons on the + released backend. +- [ ] Supersede the stale draft `v0.6.9` release record once `v0.6.10` + publishes so consumers do not see competing unpublished stable cuts. ## 7. Communication -- [ ] Announce the maintenance cut, calling out the released bundle-contract - repair and the required `comparevi-history` repin. -- [ ] Notify consumers that `v0.6.9` supersedes `v0.6.8` as the supported +- [ ] Announce the maintenance cut, calling out the decision-useful history + guidance, the Windows proof split, and the required `comparevi-history` + repin. +- [ ] Notify consumers that `v0.6.10` supersedes `v0.6.8` as the supported stable pin. ---- Updated: 2026-03-30 (prepared for the `v0.6.9` maintenance cut). +--- Updated: 2026-03-30 (prepared for the `v0.6.10` maintenance cut). From b30421cd0fdc6297d030e837cae8f77e3c31dea4 Mon Sep 17 00:00:00 2001 From: svelderrainruiz Date: Mon, 30 Mar 2026 22:42:03 -0700 Subject: [PATCH 15/16] Fix release Windows planner binding --- .github/workflows/validate.yml | 3 ++- .../__tests__/resolve-selfhosted-windows-lane-plan.test.mjs | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index e7def6867..f6d2cf760 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -1534,6 +1534,7 @@ jobs: 'capability-ingress', 'docker-lane' ) + $requiredHealthReceipts = @() pwsh -NoLogo -NoProfile -File tools/Resolve-SelfHostedWindowsLanePlan.ps1 ` -Repository '${{ github.repository }}' ` -RequiredLabels $requiredLabels ` @@ -1541,7 +1542,7 @@ jobs: -RunnerImage 'self-hosted-windows-docker-lane' ` -ExpectedContext 'desktop-windows' ` -ExpectedOs 'windows' ` - -RequiredHealthReceipts @() ` + -RequiredHealthReceipts $requiredHealthReceipts ` -Notes @( 'Availability means an online, idle repository runner advertises the ingress plus docker-lane labels.', 'The lane may mutate Docker Desktop into the Windows engine and then restores the starting context after proof capture.' diff --git a/tools/priority/__tests__/resolve-selfhosted-windows-lane-plan.test.mjs b/tools/priority/__tests__/resolve-selfhosted-windows-lane-plan.test.mjs index ec277b440..369be2938 100644 --- a/tools/priority/__tests__/resolve-selfhosted-windows-lane-plan.test.mjs +++ b/tools/priority/__tests__/resolve-selfhosted-windows-lane-plan.test.mjs @@ -119,5 +119,5 @@ test('Resolve-SelfHostedWindowsLanePlan reports unavailable when the LV32 labels assert.equal(plan.available, false); assert.equal(plan.status, 'missing-label'); assert.equal(plan.matchingRunnerCount, 0); - assert.match(plan.skipReason, /no online self-hosted Windows LV32 runner/i); + assert.match(plan.skipReason, /no online self-hosted Windows runner matched the required capability labels/i); }); From c472531eb586da3ad9bff45e9385d42e38b34a2e Mon Sep 17 00:00:00 2001 From: svelderrainruiz Date: Tue, 31 Mar 2026 04:34:51 -0700 Subject: [PATCH 16/16] Invoke Windows planner directly in release lane --- .github/workflows/validate.yml | 32 +++++++++++-------- ...date-vi-history-dispatch-contract.test.mjs | 4 ++- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index f6d2cf760..6e950e414 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -1535,22 +1535,26 @@ jobs: 'docker-lane' ) $requiredHealthReceipts = @() - pwsh -NoLogo -NoProfile -File tools/Resolve-SelfHostedWindowsLanePlan.ps1 ` - -Repository '${{ github.repository }}' ` - -RequiredLabels $requiredLabels ` - -ExecutionModel 'self-hosted-windows-docker-lane' ` - -RunnerImage 'self-hosted-windows-docker-lane' ` - -ExpectedContext 'desktop-windows' ` - -ExpectedOs 'windows' ` - -RequiredHealthReceipts $requiredHealthReceipts ` - -Notes @( + $plannerArgs = @{ + Repository = '${{ github.repository }}' + RequiredLabels = $requiredLabels + ExecutionModel = 'self-hosted-windows-docker-lane' + RunnerImage = 'self-hosted-windows-docker-lane' + ExpectedContext = 'desktop-windows' + ExpectedOs = 'windows' + Notes = @( 'Availability means an online, idle repository runner advertises the ingress plus docker-lane labels.', 'The lane may mutate Docker Desktop into the Windows engine and then restores the starting context after proof capture.' - ) ` - -Token $env:GITHUB_TOKEN ` - -OutputJsonPath $planPath ` - -GitHubOutputPath $env:GITHUB_OUTPUT ` - -StepSummaryPath $env:GITHUB_STEP_SUMMARY + ) + Token = $env:GITHUB_TOKEN + OutputJsonPath = $planPath + GitHubOutputPath = $env:GITHUB_OUTPUT + StepSummaryPath = $env:GITHUB_STEP_SUMMARY + } + if ($requiredHealthReceipts.Count -gt 0) { + $plannerArgs.RequiredHealthReceipts = $requiredHealthReceipts + } + & tools/Resolve-SelfHostedWindowsLanePlan.ps1 @plannerArgs - name: Append VI history Windows runner plan shell: pwsh diff --git a/tools/priority/__tests__/validate-vi-history-dispatch-contract.test.mjs b/tools/priority/__tests__/validate-vi-history-dispatch-contract.test.mjs index d79d868d8..36c58791b 100644 --- a/tools/priority/__tests__/validate-vi-history-dispatch-contract.test.mjs +++ b/tools/priority/__tests__/validate-vi-history-dispatch-contract.test.mjs @@ -66,7 +66,9 @@ test('validate workflow Windows VI-history lane is gated by shared dispatch plan assert.match(planSection, /permissions:\s*\r?\n\s+contents: read/); assert.match(planSection, /Resolve self-hosted Windows Docker lane/); assert.match(planSection, /tools\/Resolve-SelfHostedWindowsLanePlan\.ps1/); - assert.match(planSection, /-RequiredLabels \$requiredLabels/); + assert.match(planSection, /\$plannerArgs = @\{/); + assert.match(planSection, /RequiredLabels = \$requiredLabels/); + assert.match(planSection, /& tools\/Resolve-SelfHostedWindowsLanePlan\.ps1 @plannerArgs/); assert.match(planSection, /docker-lane/); assert.match(planSection, /outputs:\s*\r?\n\s+available:\s+\$\{\{\s*steps\.plan\.outputs\.available\s*\}\}/);
    SignalCollapsed NoiseLineageSignalCollapsed NoiseLineage