@lcv-ideas-software/cross-review 4.0.4 → 4.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,88 @@ standard `v00.00.00`; npm package versions remain SemVer.
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [v04.00.06] — 2026-05-16
11
+
12
+ **Patch — Windows-safe npm registry artifact verifier.** This release closes
13
+ the v4.0.5 audit's LOW Windows finding without changing the public MCP tool
14
+ surface.
15
+
16
+ ### Fixed
17
+
18
+ - **Registry verifier on Windows** —
19
+ `scripts/verify-registry-dist.mjs` no longer spawns `npm.cmd` through
20
+ `execFileSync`. Newer Node.js builds reject that batch-file spawn path with
21
+ `spawnSync npm.cmd EINVAL` on Windows after the CVE-2024-27980 hardening,
22
+ which broke local `npm --registry=https://registry.npmjs.org run
23
+ release:verify-registry` for Windows operators. The verifier now fetches
24
+ `https://registry.npmjs.org/<package>/<version>` directly and validates
25
+ `dist.shasum`, `dist.integrity`, and `dist.tarball` from the registry JSON.
26
+
27
+ ### Tests
28
+
29
+ - Extended `registry_dist_metadata_verification_test` to pin the no-spawn
30
+ invariant and require direct npm registry metadata lookup.
31
+
32
+ ## [v04.00.05] — 2026-05-15
33
+
34
+ **Patch — hard-gate close-out for the Codex v4.0.4 audit.** This release
35
+ closes the 6 residual findings left after v4.0.4 restored Prettier coverage.
36
+
37
+ ### Fixed
38
+
39
+ - **AUDIT-1 (StepSecurity)** — existing actionable
40
+ `Source-Code-Overwritten` detections for generated `dist/*` publish
41
+ artifacts were suppressed through the existing narrow post-rename
42
+ StepSecurity rule: repo `cross-review`, workflow
43
+ `.github/workflows/publish.yml`, job `Pre-publish gate (test + metadata)`,
44
+ file path `*/dist/*`. The rule remains scoped to generated publish output
45
+ and does not hide source-tree overwrites outside `dist/`.
46
+ - **AUDIT-2 (model-selection docs)** — `docs/model-selection.md` now uses
47
+ the post-v4 product name, removes misleading fallback wording from current
48
+ model behavior, scopes older provider-doc notes as historical, and links to
49
+ the real historical report
50
+ `docs/reports/cross-review-v2-api-capability-smoke-2026-04-30.md`.
51
+ - **AUDIT-3 (no-fallback wording)** —
52
+ `src/peers/model-selection.ts` now describes failure paths as keeping the
53
+ configured model pin instead of using the old fallback phrase; the internal
54
+ selection parameter name was aligned to `configuredPin`.
55
+ - **AUDIT-4 (agent rename history)** — `.github/copilot-instructions.md` and
56
+ `.ai/GEMINI.md` now preserve the historical package transition as
57
+ `@lcv-ideas-software/cross-review-v2` →
58
+ `@lcv-ideas-software/cross-review`, instead of the tautological
59
+ post-rename name-to-itself text.
60
+ - **AUDIT-5 (tag hygiene)** — release verification now treats the remote
61
+ padded tag as authoritative and local clones should fetch tags before
62
+ using `git tag --points-at HEAD` as evidence.
63
+ - **AUDIT-6 (artifact identity)** — new
64
+ `npm --registry=https://registry.npmjs.org run release:verify-registry`
65
+ validates npm registry `dist.shasum`, `dist.integrity`, and `dist.tarball`
66
+ via `scripts/verify-registry-dist.mjs`; the publish workflow runs it after
67
+ npmjs.com visibility succeeds so future audits do not confuse local
68
+ `npm --registry=https://registry.npmjs.org pack --dry-run` output with
69
+ published registry identity.
70
+ - **GHA npm registry discipline** — every active GitHub Actions npm command
71
+ outside dependency installation now passes
72
+ `--registry=https://registry.npmjs.org`; GitHub Packages publish commands keep
73
+ that default registry flag and override only the package scope registry.
74
+ - **Grok `-latest` model-match dot aliases** — `BasePeerAdapter.modelMatches()`
75
+ now treats `grok-4-latest` resolving provider-side to dot-release ids such as
76
+ `grok-4.3` as the same Grok 4 family, while still rejecting true cross-family
77
+ downgrades such as `grok-3-*`. This closes the live HARD GATE false positive
78
+ where Grok returned a READY verdict but the runtime rejected it as
79
+ `silent_model_downgrade`.
80
+
81
+ ### Tests
82
+
83
+ - Added smoke markers for model-selection documentation/link hygiene,
84
+ no-fallback wording, agent-instruction rename history, and registry
85
+ artifact metadata verification.
86
+ - Added `npm_registry_discipline_test` to keep active GHA npm commands and
87
+ nested package scripts on the explicit npmjs registry unless the command is
88
+ dependency installation/update.
89
+ - Extended `model_match_latest_alias_test` to pin
90
+ `grok-4-latest` → `grok-4.3` alongside the existing dated-id alias case.
91
+
10
92
  ## [v04.00.04] — 2026-05-15
11
93
 
12
94
  **Patch — restore prettier coverage of `src/` and `scripts/` (close audit
package/README.md CHANGED
@@ -21,7 +21,7 @@ npm install -g @lcv-ideas-software/cross-review
21
21
  npm install -g @lcv-ideas-software/cross-review --registry=https://npm.pkg.github.com
22
22
  ```
23
23
 
24
- **Status.** Stable. Current release: **v04.00.04** (npm package `4.0.4`). See
24
+ **Status.** Stable. Current release: **v04.00.06** (npm package `4.0.6`). See
25
25
  [CHANGELOG.md](./CHANGELOG.md) for the release history.
26
26
 
27
27
  > **Project renamed 2026-05-15.** This project was previously published as
@@ -36,6 +36,8 @@ The version history at a glance:
36
36
 
37
37
  | Release | Scope |
38
38
  |---|---|
39
+ | **`v04.00.06`** | **Patch — Windows-safe registry verifier.** `scripts/verify-registry-dist.mjs` now queries `https://registry.npmjs.org` directly instead of spawning `npm.cmd`, closing the Windows Node hardening failure (`spawnSync npm.cmd EINVAL`) while preserving the post-publish validation of registry `dist.shasum`, `dist.integrity`, and `dist.tarball`. |
40
+ | **`v04.00.05`** | **Patch — hard-gate close-out for the Codex v4.0.4 audit.** Clears the 6 residual findings: StepSecurity `Source-Code-Overwritten` detections for generated `dist/*` publish artifacts are suppressed against the existing narrow post-rename rule; `docs/model-selection.md` now uses the post-v4 product name, removes misleading fallback wording, and links to the real historical v2 capability-smoke report; model-selection failure text now says it keeps the configured model pin instead of the old fallback phrase; Copilot/Gemini agent instructions preserve the `cross-review-v2` → `cross-review` rename history; local tag verification is expected to use fetched remote tags; the publish workflow now records npm registry `dist.shasum` / `dist.integrity` / `dist.tarball` metadata so audits do not confuse local `npm --registry=https://registry.npmjs.org pack --dry-run` output with the published artifact identity; and `grok-4-latest` model-match accepts provider-reported dot-release aliases such as `grok-4.3` without weakening true cross-family downgrade rejection. |
39
41
  | **`v04.00.04`** | **Patch — restore prettier coverage of `src/` and `scripts/` (close audit on v4.0.3 hard-gate gap).** v4.0.3 added biome but also moved `src/**/*.ts`, `src/**/*.js`, `scripts/**/*.ts`, `scripts/**/*.js` into `.prettierignore` to dodge a biome↔prettier disagreement on dynamic-import call-style. Net effect: prettier ran against zero JS/TS under `src/`/`scripts/`, silently turning one of the four hard-gate checks into a no-op there. v4.0.4 restores full coverage and resolves the disagreement at the source — the 7 `scripts/smoke.ts` dynamic-import sites that triggered the wrap conflict were rewritten from destructure-from-call form to a 2-statement form (`const mod = await import("..."); const { A, B, C } = mod;`). Functionally identical; static type inference preserved. Both formatters now check the full JS/TS surface and pass simultaneously. |
40
42
  | **`v04.00.00`** | **Major — project renamed to `cross-review`** (drops the `-v2` suffix after the companion `cross-review-v1` project was discontinued and archived 2026-05-15). Breaking: npm package `@lcv-ideas-software/cross-review-v2` → `@lcv-ideas-software/cross-review` (old name stays on npm at `3.7.5` for historical installs); binaries `cross-review-v2` / `cross-review-v2-dashboard` → `cross-review` / `cross-review-dashboard`; env-var prefix `CROSS_REVIEW_V2_*` → `CROSS_REVIEW_*` across all config knobs that previously carried the `V2` infix (e.g. `CROSS_REVIEW_DATA_DIR`, `CROSS_REVIEW_DISABLE_CACHE_ANTHROPIC`); API-key env vars unchanged; per-host identity env vars (`CROSS_REVIEW_CALLER_TOKEN`, `CROSS_REVIEW_REQUIRE_TOKEN`) unchanged. GitHub repo URL: `LCV-Ideas-Software/cross-review-v2` → `LCV-Ideas-Software/cross-review` (auto-redirected). GitHub Pages: `cross-review-v2.lcv.dev` → `cross-review.lcv.dev`. MCP server key in host configs: operators who declared `cross-review-v2` rename to `cross-review`; after reload, MCP tool prefix becomes `mcp__cross-review__*`. Data dir migration is manual: operators copy `${HOME}/.cross-review/data_v2/*` into the new default `${HOME}/.cross-review/data/` (or set `CROSS_REVIEW_DATA_DIR` to the legacy path) — the v4.0.0 runtime reads only `CROSS_REVIEW_DATA_DIR` and does not fall back to the `_v2` suffix automatically. Preserved when copied: persisted session data, `config.json`, `host-tokens.json`, `cache_manifest.json`, archived/corrupt session dirs. Wire shape of all MCP tools, event types, convergence semantics is unchanged; all capabilities, peers, models, security defenses carry over from v3.7.5 verbatim. 504 source/script/doc text substitutions across 26 files. |
41
43
  | **`v03.07.05`** | **Patch — logs+sessions study 2026-05-15 close-out (4 surgical fixes from 244-session/429-round corpus).** **A1** — `session_doctor` classified cancelled sessions as `stale` (22 of 244 false positives); doctor now treats any terminal outcome (`aborted`/`converged`/`max-rounds`) as NOT-stale regardless of the persisted `convergence_health.state`. Source-layer state untouched (backward-compat with existing sessions). **A2** — `lockCallerPeerSelection` emitted false-positive `session.caller_peer_selection_ignored` events when callers passed a panel identical to the enabled set (13 of 106 recent events); the lock now accepts an optional `enabledPeers` snapshot in its context and short-circuits the emit when the caller-supplied list set-equals the enabled set (sorted comparison). **A3** — per-provider cache disable env vars (`CROSS_REVIEW_DISABLE_CACHE_ANTHROPIC|OPENAI|GEMINI|DEEPSEEK|GROK|PERPLEXITY`; provider names match v2.21.0 `_CACHE_TTL_*` convention; same parsing as `peer_enabled`); Anthropic default flipped to disabled based on empirical 0.3% hit-rate ($1.18 wasted to save $0.0035 over 244 sessions). Global `CROSS_REVIEW_DISABLE_CACHE` kill-switch unchanged; per-provider is an additive layer. Anthropic adapter `buildSystemBlock` + short-prefix warning gated on the per-provider flag; central `config.json` `cache` block accepts the new disable keys. **B1** — `session_sweep` gains opt-in `prune_corrupt: boolean.default(false)` + `corrupt_min_age_days: number.int.default(30)` to clean `<data_dir>/corrupt_sessions/` (no prior automated cleanup; 1 stale entry from 2026-05-08 v2.25.1 redact bug still on disk at study time). New `store.pruneCorruptSessions(minAgeMs)` returns `{scanned, removed, kept}`. Response shape stays `SessionMeta[]` when `prune_corrupt: false` (default); wraps to `{ swept, pruned_corrupt }` when true. **Patch bump** (3.7.4 → 3.7.5). |
@@ -135,7 +137,7 @@ Build and run locally:
135
137
 
136
138
  ```bash
137
139
  npm install
138
- npm run build
140
+ npm --registry=https://registry.npmjs.org run build
139
141
  node dist/src/mcp/server.js
140
142
  ```
141
143
 
@@ -143,7 +145,7 @@ For local smoke tests (no-cost):
143
145
 
144
146
  ```powershell
145
147
  $env:CROSS_REVIEW_STUB = "1"
146
- npm test
148
+ npm --registry=https://registry.npmjs.org test
147
149
  ```
148
150
 
149
151
  ## Configuration
@@ -639,13 +639,12 @@ assert.equal(mismatch.round.rejected.at(-1)?.failure_class, "silent_model_downgr
639
639
  assert.equal(mismatch.session.failed_attempts?.at(-1)?.failure_class, "silent_model_downgrade");
640
640
  // v3.7.4 (operator-directed, session ecd03404): `model_match` must
641
641
  // recognize a `-latest` alias resolving to a concrete dated id. xAI
642
- // returns `grok-4-0709` for the pinned `grok-4-latest`; pre-v3.7.4
643
- // `modelMatches()` flagged that as `silent_model_downgrade` because
644
- // `grok-4-0709` does not start with the literal `grok-4-latest-`. That
645
- // forced `status` to null (base.ts:315) and rejected the peer, making
646
- // unanimity unreachable on any panel including grok. The fix strips the
647
- // `-latest` suffix to the family stem and matches the reported id
648
- // against it; a genuine cross-family downgrade is still flagged.
642
+ // returns family ids for the pinned `grok-4-latest`; pre-v3.7.4
643
+ // `modelMatches()` flagged `grok-4-0709` as `silent_model_downgrade`
644
+ // because it does not start with the literal `grok-4-latest-`. v4.0.5
645
+ // extends the same family-stem rule to dot-release ids observed in live
646
+ // xAI responses (`grok-4.3`). These are alias resolution, not downgrade.
647
+ // A genuine cross-family downgrade is still flagged.
649
648
  {
650
649
  const aliasStub = new StubAdapter(config, "grok", "grok-4-latest");
651
650
  process.env.CROSS_REVIEW_STUB_REPORTED_MODEL = "grok-4-0709";
@@ -658,6 +657,17 @@ assert.equal(mismatch.session.failed_attempts?.at(-1)?.failure_class, "silent_mo
658
657
  delete process.env.CROSS_REVIEW_STUB_REPORTED_MODEL;
659
658
  assert.equal(aliasResult.model_match, true, `v3.7.4 / model-match: a \`-latest\` alias resolving to a concrete dated id (grok-4-latest → grok-4-0709) MUST match — not trip silent_model_downgrade (got model_match=${aliasResult.model_match})`);
660
659
  assert.notEqual(aliasResult.status, null, "v3.7.4 / model-match: a matched `-latest` alias must NOT force status to null (base.ts:315)");
660
+ const dotAliasStub = new StubAdapter(config, "grok", "grok-4-latest");
661
+ process.env.CROSS_REVIEW_STUB_REPORTED_MODEL = "grok-4.3";
662
+ const dotAliasResult = await dotAliasStub.call("model-match -latest dot alias probe", {
663
+ session_id: result.session.session_id,
664
+ round: 98,
665
+ task: "model-match -latest dot alias probe",
666
+ emit() { },
667
+ });
668
+ delete process.env.CROSS_REVIEW_STUB_REPORTED_MODEL;
669
+ assert.equal(dotAliasResult.model_match, true, `v4.0.5 / model-match: a \`-latest\` alias resolving to a dot-release id (grok-4-latest → grok-4.3) MUST match — not trip silent_model_downgrade (got model_match=${dotAliasResult.model_match})`);
670
+ assert.notEqual(dotAliasResult.status, null, "v4.0.5 / model-match: a matched `-latest` dot alias must NOT force status to null");
661
671
  const downgradeAliasStub = new StubAdapter(config, "grok", "grok-4-latest");
662
672
  process.env.CROSS_REVIEW_STUB_REPORTED_MODEL = "grok-3-fast";
663
673
  const downgradeAliasResult = await downgradeAliasStub.call("model-match cross-family downgrade probe", {
@@ -670,7 +680,9 @@ assert.equal(mismatch.session.failed_attempts?.at(-1)?.failure_class, "silent_mo
670
680
  assert.equal(downgradeAliasResult.model_match, false, `v3.7.4 / model-match: a genuine cross-family downgrade (grok-4-latest → grok-3-fast) MUST still be flagged (got model_match=${downgradeAliasResult.model_match})`);
671
681
  // Source pin: base.ts modelMatches must carry the `-latest` family-stem branch.
672
682
  const baseSrc = fs.readFileSync(path.resolve(process.cwd(), "src", "peers", "base.ts"), "utf8");
673
- assert.ok(/endsWith\("-latest"\)/.test(baseSrc) && /familyStem/.test(baseSrc), "v3.7.4 / model-match: base.ts modelMatches must handle the `-latest` alias via a family-stem match");
683
+ assert.ok(/endsWith\("-latest"\)/.test(baseSrc) &&
684
+ /reportedModel\.startsWith\(`\$\{familyStem\}-`\)/.test(baseSrc) &&
685
+ /reportedModel\.startsWith\(`\$\{familyStem\}\.`\)/.test(baseSrc), "v4.0.5 / model-match: base.ts modelMatches must handle `-latest` aliases via hyphen and dot family-stem matches");
674
686
  console.log("[smoke] model_match_latest_alias_test: PASS");
675
687
  }
676
688
  const focusSecret = ["sk", "test", "B".repeat(24)].join("-");
@@ -6151,6 +6163,101 @@ assert.equal(Object.hasOwn(metrics.decision_quality, "undefined"), false);
6151
6163
  assert.equal(pl.name, "@lcv-ideas-software/cross-review", `v4.0.2 / AUDIT-1: package-lock.json .name must be "@lcv-ideas-software/cross-review"; got "${pl.name}".`);
6152
6164
  console.log("[smoke] package_version_consistency_test: PASS");
6153
6165
  }
6166
+ // v4.0.5 (AUDIT-2..6, Codex hard-gate close-out 2026-05-15):
6167
+ // anti-drift checks for post-rename docs, no-fallback wording, registry
6168
+ // artifact verification and agent-instruction history.
6169
+ {
6170
+ const docsPath = path.join(process.cwd(), "docs", "model-selection.md");
6171
+ const docsSrc = fs.readFileSync(docsPath, "utf8");
6172
+ const staleV2ThinkingPhrase = "Cross-review-" + "v2 is optimized";
6173
+ const staleGeminiFallbackPhrase = "Gemini 2.5 Pro " + "fallback";
6174
+ const missingCapabilityReport = "docs/reports/cross-review" + "-api-capability-smoke-2026-04-30.md";
6175
+ assert.ok(docsSrc.includes("Cross-review is optimized for correctness over latency and cost."), "v4.0.5 / AUDIT-2: docs/model-selection.md must use the post-v4 product name in the thinking section.");
6176
+ assert.ok(!docsSrc.includes(staleV2ThinkingPhrase), "v4.0.5 / AUDIT-2: stale v2 product-name wording must not return.");
6177
+ assert.ok(!docsSrc.includes(staleGeminiFallbackPhrase), "v4.0.5 / AUDIT-2: Gemini docs must not describe the pinned model as a fallback.");
6178
+ assert.ok(!docsSrc.includes(missingCapabilityReport), "v4.0.5 / AUDIT-2: docs must not link to the missing post-rename capability-smoke filename.");
6179
+ assert.ok(docsSrc.includes("docs/reports/cross-review-v2-api-capability-smoke-2026-04-30.md"), "v4.0.5 / AUDIT-2: docs must link to the existing historical v2 capability-smoke report.");
6180
+ assert.ok(fs.existsSync(path.join(process.cwd(), "docs", "reports", "cross-review-v2-api-capability-smoke-2026-04-30.md")), "v4.0.5 / AUDIT-2: linked historical capability-smoke report must exist.");
6181
+ console.log("[smoke] docs_model_selection_rename_and_link_test: PASS");
6182
+ }
6183
+ {
6184
+ const modelSelectionSrc = fs.readFileSync(path.join(process.cwd(), "src", "peers", "model-selection.ts"), "utf8");
6185
+ const staleFallbackReason = "using the current " + "fallback";
6186
+ assert.ok(!modelSelectionSrc.includes(staleFallbackReason), "v4.0.5 / AUDIT-3: model-selection reason text must not claim fallback use.");
6187
+ assert.ok(modelSelectionSrc.includes("keeping the configured model pin"), "v4.0.5 / AUDIT-3: model-selection failure paths must describe keeping the configured model pin.");
6188
+ assert.ok(modelSelectionSrc.includes("no-fallback policy"), "v4.0.5 / AUDIT-3: no-fallback policy wording must remain visible in model selection.");
6189
+ console.log("[smoke] model_selection_no_fallback_wording_test: PASS");
6190
+ }
6191
+ {
6192
+ const instructionFiles = [
6193
+ ["copilot", path.join(process.cwd(), ".github", "copilot-instructions.md")],
6194
+ ["gemini", path.join(process.cwd(), ".ai", "GEMINI.md")],
6195
+ ];
6196
+ for (const [label, filePath] of instructionFiles) {
6197
+ if (!fs.existsSync(filePath))
6198
+ continue;
6199
+ const src = fs.readFileSync(filePath, "utf8");
6200
+ assert.ok(src.includes("renomeado de `@lcv-ideas-software/cross-review-v2` no rename total Phase 2"), `v4.0.5 / AUDIT-4: ${label} agent instructions must preserve cross-review-v2 -> cross-review history.`);
6201
+ assert.ok(!src.includes("renomeado de `@lcv-ideas-software/cross-review` no rename total Phase 2"), `v4.0.5 / AUDIT-4: ${label} agent instructions must not contain the tautological rename.`);
6202
+ }
6203
+ console.log("[smoke] agent_instruction_rename_history_test: PASS");
6204
+ }
6205
+ {
6206
+ const pkg = JSON.parse(fs.readFileSync(path.join(process.cwd(), "package.json"), "utf8"));
6207
+ const script = String(pkg.scripts?.["release:verify-registry"] ?? "");
6208
+ assert.ok(script.includes("verify-registry-dist.mjs"), "v4.0.5 / AUDIT-6: package.json must expose release:verify-registry.");
6209
+ const verifyScript = fs.readFileSync(path.join(process.cwd(), "scripts", "verify-registry-dist.mjs"), "utf8");
6210
+ assert.ok(!verifyScript.includes("node:child_process"), "v4.0.6 / F1: verify-registry-dist.mjs must not spawn npm/npm.cmd; Windows Node hardening rejects npm.cmd spawn.");
6211
+ assert.ok(verifyScript.includes("https://registry.npmjs.org") && verifyScript.includes("fetch("), "v4.0.6 / F1: verify-registry-dist.mjs must query npm registry metadata directly.");
6212
+ for (const required of ["dist", "shasum", "integrity", "tarball"]) {
6213
+ assert.ok(verifyScript.includes(required), `v4.0.5 / AUDIT-6: verify-registry-dist.mjs must validate npm registry dist.${required}.`);
6214
+ }
6215
+ const publishWorkflow = fs.readFileSync(path.join(process.cwd(), ".github", "workflows", "publish.yml"), "utf8");
6216
+ assert.ok(publishWorkflow.includes("npm --registry=https://registry.npmjs.org run release:verify-registry"), "v4.0.5 / AUDIT-6: publish workflow must verify npm registry artifact metadata after publication.");
6217
+ console.log("[smoke] registry_dist_metadata_verification_test: PASS");
6218
+ }
6219
+ {
6220
+ const npmRegistryArg = "--registry=https://registry.npmjs.org";
6221
+ const isAllowedNpmCommand = (command) => {
6222
+ const afterNpm = command.trim().replace(/^.*?\bnpm\s+/, "");
6223
+ return /^(ci|install|update)\b/.test(afterNpm) || afterNpm.startsWith(npmRegistryArg);
6224
+ };
6225
+ const extractNpmShellCommand = (line) => {
6226
+ const trimmed = line.trim();
6227
+ if (!trimmed || trimmed.startsWith("#"))
6228
+ return undefined;
6229
+ if (trimmed.startsWith("run: npm "))
6230
+ return trimmed.slice("run: ".length);
6231
+ if (trimmed.startsWith("npm "))
6232
+ return trimmed;
6233
+ if (trimmed.startsWith("if npm "))
6234
+ return trimmed.slice("if ".length);
6235
+ return undefined;
6236
+ };
6237
+ for (const workflowPath of [
6238
+ path.join(process.cwd(), ".github", "workflows", "ci.yml"),
6239
+ path.join(process.cwd(), ".github", "workflows", "publish.yml"),
6240
+ ]) {
6241
+ const workflowSrc = fs.readFileSync(workflowPath, "utf8");
6242
+ workflowSrc.split(/\r?\n/).forEach((line, index) => {
6243
+ const command = extractNpmShellCommand(line);
6244
+ if (!command)
6245
+ return;
6246
+ assert.ok(isAllowedNpmCommand(command), `v4.0.5 / npm-registry: ${path.basename(workflowPath)}:${index + 1} must pass ${npmRegistryArg} unless it is dependency install/update.`);
6247
+ });
6248
+ assert.ok(!workflowSrc.includes("execFileSync('npm', ['--version']"), `v4.0.5 / npm-registry: ${path.basename(workflowPath)} npm subprocess checks must pass ${npmRegistryArg}.`);
6249
+ }
6250
+ const pkg = JSON.parse(fs.readFileSync(path.join(process.cwd(), "package.json"), "utf8"));
6251
+ for (const [name, script] of Object.entries(pkg.scripts ?? {})) {
6252
+ for (const part of script.split("&&")) {
6253
+ const trimmed = part.trim();
6254
+ if (!trimmed.startsWith("npm "))
6255
+ continue;
6256
+ assert.ok(isAllowedNpmCommand(trimmed), `v4.0.5 / npm-registry: package script ${name} must pass ${npmRegistryArg} unless it is dependency install/update.`);
6257
+ }
6258
+ }
6259
+ console.log("[smoke] npm_registry_discipline_test: PASS");
6260
+ }
6154
6261
  // v2.6.1 NOTE: smoke coverage for `peer.fallback.budget_blocked` and
6155
6262
  // `peer.moderation_recovery.budget_blocked` is intentionally NOT
6156
6263
  // included. These two gates use the same arithmetic shape as preflight