diff --git a/.agents/skills/nemoclaw-contributor-update-dependencies/SKILL.md b/.agents/skills/nemoclaw-contributor-update-dependencies/SKILL.md index 64686c63d10..469089ea499 100644 --- a/.agents/skills/nemoclaw-contributor-update-dependencies/SKILL.md +++ b/.agents/skills/nemoclaw-contributor-update-dependencies/SKILL.md @@ -436,7 +436,7 @@ Use these NemoClaw precedents for durable evidence shape, not as inherited concl - `docs/security/openclaw-2026.6.10-dependency-review.md` and `test/openclaw-dependency-review.test.ts` for a tracked dependency review with contract tests; - `docs/security/openshell-0.0.72-compatibility-review.mdx` for a runtime compatibility boundary; -- `scripts/checks/dependency-pins.ts` and `test/dependency-pins-check.test.ts` for selector +- `scripts/checks/dependency-pins.mts` and `test/dependency-pins-check.test.ts` for selector coherence; and - `scripts/check-installer-hash.sh` and `test/installer-hash-check.test.ts` for independently trusted release manifests and consumed artifacts. diff --git a/.github/actions/ci-cli-coverage-shard/action.yaml b/.github/actions/ci-cli-coverage-shard/action.yaml index 4bb786089b1..30cd932b654 100644 --- a/.github/actions/ci-cli-coverage-shard/action.yaml +++ b/.github/actions/ci-cli-coverage-shard/action.yaml @@ -91,7 +91,7 @@ runs: exit 0 ;; esac - npx tsx scripts/checks/e2e-mock-parity.ts --base "$base" --head "$head" + npx tsx scripts/checks/e2e-mock-parity.mts --base "$base" --head "$head" - name: Build TypeScript plugin shell: bash diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 05d9fff357a..8a3e4e1b7a8 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -339,7 +339,7 @@ jobs: # covered without executing a mutable replacement action. - name: Validate changed live E2E mock parity (bootstrap) if: ${{ steps.trusted-shard-capabilities.outputs.e2e-support != 'true' && matrix.shard == 1 }} - run: npx tsx scripts/checks/e2e-mock-parity.ts --base HEAD^1 --head HEAD^2 + run: npx tsx scripts/checks/e2e-mock-parity.mts --base HEAD^1 --head HEAD^2 - name: Run E2E support shard (bootstrap) if: ${{ steps.trusted-shard-capabilities.outputs.e2e-support != 'true' }} diff --git a/package.json b/package.json index c8b2029d677..d63c2dcb8a6 100644 --- a/package.json +++ b/package.json @@ -35,13 +35,13 @@ "test:coverage:cli": "npm run clean:cli && npm run build:cli && tsx scripts/check-dist-sourcemaps.mts dist && vitest run --project cli --project integration --coverage --coverage.reporter=text-summary --coverage.reporter=json-summary --coverage.reportsDirectory=coverage/cli --coverage.include=\"bin/**/*.js\" --coverage.include=\"src/**/*.ts\" --coverage.exclude=\"test/**/*.js\" --coverage.exclude=\"test/**/*.ts\" && tsx scripts/check-coverage-ratchet.mts coverage/cli/coverage-summary.json ci/coverage-threshold-cli.json \"CLI coverage\"", "test:coverage:plugin": "vitest run --project plugin --coverage --coverage.reporter=text-summary --coverage.reporter=json-summary --coverage.reportsDirectory=coverage/plugin --coverage.include=\"nemoclaw/src/**/*.ts\" --coverage.include=\"nemoclaw/src/**/*.cts\" --coverage.exclude=\"**/*.test.ts\" && tsx scripts/check-coverage-ratchet.mts coverage/plugin/coverage-summary.json ci/coverage-threshold-plugin.json \"Plugin coverage\"", "test:live-e2e": "npm run clean:cli && npm run build:cli && NEMOCLAW_RUN_LIVE_E2E=1 vitest run --project e2e-live", - "test:imports:check": "tsx scripts/checks/no-test-dist-imports.ts", - "test:projects:check": "tsx scripts/checks/vitest-project-overlap.ts", - "test:titles:check": "tsx scripts/checks/test-title-style.ts", + "test:imports:check": "tsx scripts/checks/no-test-dist-imports.mts", + "test:projects:check": "tsx scripts/checks/vitest-project-overlap.mts", + "test:titles:check": "tsx scripts/checks/test-title-style.mts", "bench": "tsx scripts/bench/run.mts", "check": "npx prek run --all-files --stage pre-commit && npx prek run --all-files --stage manual", "check:diff": "npx prek run --from-ref origin/main --to-ref HEAD --stage pre-commit && npx commitlint --from origin/main --to HEAD && npx prek run --from-ref origin/main --to-ref HEAD --stage pre-push", - "checks": "tsx scripts/checks/run.ts", + "checks": "tsx scripts/checks/run.mts", "lint": "npx @biomejs/biome lint . && npm run checks", "lint:fix": "npx @biomejs/biome lint --write . && npm run checks", "lint:ts": "cd nemoclaw && npm run check", diff --git a/scripts/checks/dependency-pins.ts b/scripts/checks/dependency-pins.mts similarity index 100% rename from scripts/checks/dependency-pins.ts rename to scripts/checks/dependency-pins.mts diff --git a/scripts/checks/direct-credential-env.ts b/scripts/checks/direct-credential-env.mts similarity index 98% rename from scripts/checks/direct-credential-env.ts rename to scripts/checks/direct-credential-env.mts index 0873c5ab913..e203a521e10 100644 --- a/scripts/checks/direct-credential-env.ts +++ b/scripts/checks/direct-credential-env.mts @@ -13,8 +13,8 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import * as ts from "typescript"; -import { SUPPORTED_CREDENTIAL_ENV_NAMES } from "../../src/lib/security/credential-env"; +const { SUPPORTED_CREDENTIAL_ENV_NAMES } = await import("../../src/lib/security/credential-env"); const CREDENTIAL_ENV_KEYS = SUPPORTED_CREDENTIAL_ENV_NAMES; const MESSAGE = @@ -251,7 +251,7 @@ function scriptKindForPath(filePath: string): ts.ScriptKind { function main(): void { const filePaths = process.argv.slice(2).filter((arg) => arg !== "--"); if (filePaths.length === 0) { - console.error("Usage: tsx scripts/checks/direct-credential-env.ts FILE..."); + console.error("Usage: tsx scripts/checks/direct-credential-env.mts FILE..."); process.exitCode = 2; return; } diff --git a/scripts/checks/e2e-mock-parity.mts b/scripts/checks/e2e-mock-parity.mts new file mode 100644 index 00000000000..b5c57db022e --- /dev/null +++ b/scripts/checks/e2e-mock-parity.mts @@ -0,0 +1,200 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import ts from "typescript"; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +export const DEFAULT_PARITY_MANIFEST = "test/e2e/mock-parity.json"; + +export type MockParityEntry = { + live: string; + fast?: string[]; + liveOnlyReason?: string; +}; + +export type MockParityManifest = { + version: 1; + entries: MockParityEntry[]; +}; + +const LIVE_TEST = /^test\/e2e\/live\/.+\.test\.ts$/u; +const FAST_TESTS = [ + /^src\/.+\.test\.ts$/u, + /^nemoclaw\/src\/.+\.test\.ts$/u, + /^test\/e2e\/support\/.+\.test\.ts$/u, + /^test\/(?!e2e\/|package-contract\/).+\.test\.(?:js|ts)$/u, +] as const; + +function sourceTokens(source: string): string { + const sourceFile = ts.createSourceFile( + "source.ts", + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + const tokens: Array<[ts.SyntaxKind, string]> = []; + const visit = (node: ts.Node): void => { + const children = node.getChildren(sourceFile); + if (children.length === 0) { + if (node.kind !== ts.SyntaxKind.EndOfFileToken) { + tokens.push([node.kind, node.getText(sourceFile)]); + } + return; + } + for (const child of children) visit(child); + }; + visit(sourceFile); + return JSON.stringify(tokens); +} + +export function isMockParityRelevantSourceChange( + baseSource: string | null, + headSource: string | null, +): boolean { + if (baseSource === null || headSource === null) return true; + return sourceTokens(baseSource) !== sourceTokens(headSource); +} + +function isSafeRepoPath(file: string): boolean { + return ( + file.length > 0 && + !path.posix.isAbsolute(file) && + !file.includes("\\") && + !file.split("/").includes("..") + ); +} + +function isFastPrTest(file: string): boolean { + return isSafeRepoPath(file) && FAST_TESTS.some((pattern) => pattern.test(file)); +} + +export function validateMockParity(options: { + manifest: MockParityManifest; + changedFiles: readonly string[]; + fileExists?: (file: string) => boolean; +}): string[] { + const { + manifest, + changedFiles, + fileExists = (file) => fs.existsSync(path.join(REPO_ROOT, file)), + } = options; + const errors: string[] = []; + + if (manifest.version !== 1 || !Array.isArray(manifest.entries)) { + return ["mock parity manifest must have version 1 and an entries array"]; + } + + const entries = new Map(); + for (const entry of manifest.entries) { + if (!entry || typeof entry !== "object" || typeof entry.live !== "string") { + errors.push("mock parity entries must be objects with a live path"); + continue; + } + if (!isSafeRepoPath(entry.live) || !LIVE_TEST.test(entry.live)) { + errors.push(`${entry.live}: live path must be a test/e2e/live/**/*.test.ts file`); + continue; + } + if (entries.has(entry.live)) { + errors.push(`${entry.live}: duplicate mock parity entry`); + continue; + } + entries.set(entry.live, entry); + + if ( + entry.fast !== undefined && + (!Array.isArray(entry.fast) || entry.fast.some((file) => typeof file !== "string")) + ) { + errors.push(`${entry.live}: fast must be an array of test paths`); + continue; + } + if (entry.liveOnlyReason !== undefined && typeof entry.liveOnlyReason !== "string") { + errors.push(`${entry.live}: liveOnlyReason must be a string`); + continue; + } + const fast = entry.fast ?? []; + const liveOnlyReason = entry.liveOnlyReason?.trim() ?? ""; + if (fast.length > 0 && liveOnlyReason) { + errors.push(`${entry.live}: choose fast tests or a live-only reason, not both`); + } else if (fast.length === 0 && !liveOnlyReason) { + errors.push(`${entry.live}: map at least one fast test or provide a live-only reason`); + } + + if (!fileExists(entry.live)) errors.push(`${entry.live}: live test does not exist`); + for (const fastFile of new Set(fast)) { + if (!isFastPrTest(fastFile)) { + errors.push(`${entry.live}: ${fastFile} is not collected by a fast PR test project`); + } else if (!fileExists(fastFile)) { + errors.push(`${entry.live}: mapped fast test does not exist: ${fastFile}`); + } + } + } + + for (const liveFile of [...new Set(changedFiles)].filter((file) => LIVE_TEST.test(file))) { + if (!entries.has(liveFile)) { + errors.push(`${liveFile}: changed live E2E needs an entry in ${DEFAULT_PARITY_MANIFEST}`); + } + } + + return errors.sort(); +} + +function argument(name: string): string | undefined { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : undefined; +} + +function sourceAtRef(ref: string, file: string): string | null { + try { + return execFileSync("git", ["show", `${ref}:${file}`], { + cwd: REPO_ROOT, + encoding: "utf8", + maxBuffer: 10 * 1024 * 1024, + }); + } catch { + return null; + } +} + +function changedFiles(base: string, head: string): string[] { + const files = execFileSync( + "git", + ["diff", "--name-only", "--diff-filter=ACMR", `${base}...${head}`], + { + cwd: REPO_ROOT, + encoding: "utf8", + }, + ) + .split(/\r?\n/u) + .filter(Boolean); + return files.filter( + (file) => + !LIVE_TEST.test(file) || + isMockParityRelevantSourceChange(sourceAtRef(base, file), sourceAtRef(head, file)), + ); +} + +export function main(): void { + const base = argument("--base"); + const head = argument("--head") ?? "HEAD"; + if (!base) throw new Error("usage: e2e-mock-parity.mts --base [--head ]"); + + const manifestPath = path.join(REPO_ROOT, DEFAULT_PARITY_MANIFEST); + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as MockParityManifest; + const errors = validateMockParity({ manifest, changedFiles: changedFiles(base, head) }); + if (errors.length > 0) { + console.error( + ["E2E mock/live parity check failed:", ...errors.map((error) => `- ${error}`)].join("\n"), + ); + process.exitCode = 1; + return; + } + console.log("E2E mock/live parity check passed."); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(); diff --git a/scripts/checks/e2e-mock-parity.ts b/scripts/checks/e2e-mock-parity.ts index b9dbf9e64e6..8682ec10448 100644 --- a/scripts/checks/e2e-mock-parity.ts +++ b/scripts/checks/e2e-mock-parity.ts @@ -1,200 +1,10 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { execFileSync } from "node:child_process"; -import fs from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; +// Compatibility boundary: the coverage-shard composite action resolves this +// exact path until it is updated to prefer e2e-mock-parity.mts. Keep this file +// forwarding to that implementation rather than duplicating its logic. -import ts from "typescript"; +import { main } from "./e2e-mock-parity.mts"; -const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); -export const DEFAULT_PARITY_MANIFEST = "test/e2e/mock-parity.json"; - -export type MockParityEntry = { - live: string; - fast?: string[]; - liveOnlyReason?: string; -}; - -export type MockParityManifest = { - version: 1; - entries: MockParityEntry[]; -}; - -const LIVE_TEST = /^test\/e2e\/live\/.+\.test\.ts$/u; -const FAST_TESTS = [ - /^src\/.+\.test\.ts$/u, - /^nemoclaw\/src\/.+\.test\.ts$/u, - /^test\/e2e\/support\/.+\.test\.ts$/u, - /^test\/(?!e2e\/|package-contract\/).+\.test\.(?:js|ts)$/u, -] as const; - -function sourceTokens(source: string): string { - const sourceFile = ts.createSourceFile( - "source.ts", - source, - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TS, - ); - const tokens: Array<[ts.SyntaxKind, string]> = []; - const visit = (node: ts.Node): void => { - const children = node.getChildren(sourceFile); - if (children.length === 0) { - if (node.kind !== ts.SyntaxKind.EndOfFileToken) { - tokens.push([node.kind, node.getText(sourceFile)]); - } - return; - } - for (const child of children) visit(child); - }; - visit(sourceFile); - return JSON.stringify(tokens); -} - -export function isMockParityRelevantSourceChange( - baseSource: string | null, - headSource: string | null, -): boolean { - if (baseSource === null || headSource === null) return true; - return sourceTokens(baseSource) !== sourceTokens(headSource); -} - -function isSafeRepoPath(file: string): boolean { - return ( - file.length > 0 && - !path.posix.isAbsolute(file) && - !file.includes("\\") && - !file.split("/").includes("..") - ); -} - -function isFastPrTest(file: string): boolean { - return isSafeRepoPath(file) && FAST_TESTS.some((pattern) => pattern.test(file)); -} - -export function validateMockParity(options: { - manifest: MockParityManifest; - changedFiles: readonly string[]; - fileExists?: (file: string) => boolean; -}): string[] { - const { - manifest, - changedFiles, - fileExists = (file) => fs.existsSync(path.join(REPO_ROOT, file)), - } = options; - const errors: string[] = []; - - if (manifest.version !== 1 || !Array.isArray(manifest.entries)) { - return ["mock parity manifest must have version 1 and an entries array"]; - } - - const entries = new Map(); - for (const entry of manifest.entries) { - if (!entry || typeof entry !== "object" || typeof entry.live !== "string") { - errors.push("mock parity entries must be objects with a live path"); - continue; - } - if (!isSafeRepoPath(entry.live) || !LIVE_TEST.test(entry.live)) { - errors.push(`${entry.live}: live path must be a test/e2e/live/**/*.test.ts file`); - continue; - } - if (entries.has(entry.live)) { - errors.push(`${entry.live}: duplicate mock parity entry`); - continue; - } - entries.set(entry.live, entry); - - if ( - entry.fast !== undefined && - (!Array.isArray(entry.fast) || entry.fast.some((file) => typeof file !== "string")) - ) { - errors.push(`${entry.live}: fast must be an array of test paths`); - continue; - } - if (entry.liveOnlyReason !== undefined && typeof entry.liveOnlyReason !== "string") { - errors.push(`${entry.live}: liveOnlyReason must be a string`); - continue; - } - const fast = entry.fast ?? []; - const liveOnlyReason = entry.liveOnlyReason?.trim() ?? ""; - if (fast.length > 0 && liveOnlyReason) { - errors.push(`${entry.live}: choose fast tests or a live-only reason, not both`); - } else if (fast.length === 0 && !liveOnlyReason) { - errors.push(`${entry.live}: map at least one fast test or provide a live-only reason`); - } - - if (!fileExists(entry.live)) errors.push(`${entry.live}: live test does not exist`); - for (const fastFile of new Set(fast)) { - if (!isFastPrTest(fastFile)) { - errors.push(`${entry.live}: ${fastFile} is not collected by a fast PR test project`); - } else if (!fileExists(fastFile)) { - errors.push(`${entry.live}: mapped fast test does not exist: ${fastFile}`); - } - } - } - - for (const liveFile of [...new Set(changedFiles)].filter((file) => LIVE_TEST.test(file))) { - if (!entries.has(liveFile)) { - errors.push(`${liveFile}: changed live E2E needs an entry in ${DEFAULT_PARITY_MANIFEST}`); - } - } - - return errors.sort(); -} - -function argument(name: string): string | undefined { - const index = process.argv.indexOf(name); - return index >= 0 ? process.argv[index + 1] : undefined; -} - -function sourceAtRef(ref: string, file: string): string | null { - try { - return execFileSync("git", ["show", `${ref}:${file}`], { - cwd: REPO_ROOT, - encoding: "utf8", - maxBuffer: 10 * 1024 * 1024, - }); - } catch { - return null; - } -} - -function changedFiles(base: string, head: string): string[] { - const files = execFileSync( - "git", - ["diff", "--name-only", "--diff-filter=ACMR", `${base}...${head}`], - { - cwd: REPO_ROOT, - encoding: "utf8", - }, - ) - .split(/\r?\n/u) - .filter(Boolean); - return files.filter( - (file) => - !LIVE_TEST.test(file) || - isMockParityRelevantSourceChange(sourceAtRef(base, file), sourceAtRef(head, file)), - ); -} - -function main(): void { - const base = argument("--base"); - const head = argument("--head") ?? "HEAD"; - if (!base) throw new Error("usage: e2e-mock-parity.ts --base [--head ]"); - - const manifestPath = path.join(REPO_ROOT, DEFAULT_PARITY_MANIFEST); - const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as MockParityManifest; - const errors = validateMockParity({ manifest, changedFiles: changedFiles(base, head) }); - if (errors.length > 0) { - console.error( - ["E2E mock/live parity check failed:", ...errors.map((error) => `- ${error}`)].join("\n"), - ); - process.exitCode = 1; - return; - } - console.log("E2E mock/live parity check passed."); -} - -if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(); +main(); diff --git a/scripts/checks/hermes-light-skin-boundary.mts b/scripts/checks/hermes-light-skin-boundary.mts new file mode 100644 index 00000000000..43539e3c7fd --- /dev/null +++ b/scripts/checks/hermes-light-skin-boundary.mts @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const { NEMOCLAW_HERMES_LIGHT_SKIN_REVIEWED_HERMES_VERSIONS } = await import( + "../../src/lib/domain/sandbox/connect-env" +); + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const HERMES_DOCKERFILE_BASE = "agents/hermes/Dockerfile.base"; + +export function checkHermesLightSkinBoundary(options: { + dockerfileText: string; + reviewedVersions: readonly string[]; +}): string | null { + const { dockerfileText, reviewedVersions } = options; + const pinnedVersion = dockerfileText.match(/^ARG HERMES_VERSION=(\S+)$/m)?.[1]; + if (!pinnedVersion) { + return `${HERMES_DOCKERFILE_BASE}: could not find ARG HERMES_VERSION`; + } + if (!reviewedVersions.includes(pinnedVersion)) { + return [ + "Hermes light terminal compatibility skin needs re-review.", + `${HERMES_DOCKERFILE_BASE} pins ${pinnedVersion}, but connect-env.ts was reviewed for ${reviewedVersions.join(", ")}.`, + "Remove the NemoClaw-managed light skin if upstream Hermes is readable in light terminals, or update the reviewed version constant after validating it still needs the shim.", + ].join(" "); + } + return null; +} + +function main(): void { + const dockerfileText = fs.readFileSync(path.join(REPO_ROOT, HERMES_DOCKERFILE_BASE), "utf8"); + const error = checkHermesLightSkinBoundary({ + dockerfileText, + reviewedVersions: NEMOCLAW_HERMES_LIGHT_SKIN_REVIEWED_HERMES_VERSIONS, + }); + if (error) throw new Error(error); +} + +if (fileURLToPath(import.meta.url) === path.resolve(process.argv[1] ?? "")) { + main(); +} diff --git a/scripts/checks/hermes-light-skin-boundary.ts b/scripts/checks/hermes-light-skin-boundary.ts deleted file mode 100644 index f9d784dbbc4..00000000000 --- a/scripts/checks/hermes-light-skin-boundary.ts +++ /dev/null @@ -1,31 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import fs from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -import { NEMOCLAW_HERMES_LIGHT_SKIN_REVIEWED_HERMES_VERSIONS } from "../../src/lib/domain/sandbox/connect-env"; - -const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); -const HERMES_DOCKERFILE_BASE = "agents/hermes/Dockerfile.base"; - -function main(): void { - const dockerfile = fs.readFileSync(path.join(REPO_ROOT, HERMES_DOCKERFILE_BASE), "utf8"); - const pinnedVersion = dockerfile.match(/^ARG HERMES_VERSION=(\S+)$/m)?.[1]; - if (!pinnedVersion) { - throw new Error(`${HERMES_DOCKERFILE_BASE}: could not find ARG HERMES_VERSION`); - } - const reviewedVersions: readonly string[] = NEMOCLAW_HERMES_LIGHT_SKIN_REVIEWED_HERMES_VERSIONS; - if (!reviewedVersions.includes(pinnedVersion)) { - throw new Error( - [ - "Hermes light terminal compatibility skin needs re-review.", - `${HERMES_DOCKERFILE_BASE} pins ${pinnedVersion}, but connect-env.ts was reviewed for ${reviewedVersions.join(", ")}.`, - "Remove the NemoClaw-managed light skin if upstream Hermes is readable in light terminals, or update the reviewed version constant after validating it still needs the shim.", - ].join(" "), - ); - } -} - -main(); diff --git a/scripts/checks/layer-import-boundaries.ts b/scripts/checks/layer-import-boundaries.mts similarity index 100% rename from scripts/checks/layer-import-boundaries.ts rename to scripts/checks/layer-import-boundaries.mts diff --git a/scripts/checks/local-credential-helper-pin.ts b/scripts/checks/local-credential-helper-pin.mts similarity index 100% rename from scripts/checks/local-credential-helper-pin.ts rename to scripts/checks/local-credential-helper-pin.mts diff --git a/scripts/checks/no-coverage-ignore.ts b/scripts/checks/no-coverage-ignore.mts similarity index 97% rename from scripts/checks/no-coverage-ignore.ts rename to scripts/checks/no-coverage-ignore.mts index 10011145773..3c518bbc920 100644 --- a/scripts/checks/no-coverage-ignore.ts +++ b/scripts/checks/no-coverage-ignore.mts @@ -15,7 +15,7 @@ import { fileURLToPath } from "node:url"; const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const SCAN_ROOTS = ["bin", "src", "scripts", "test", "nemoclaw/src"]; -const SOURCE_EXTENSIONS = new Set([".cjs", ".cts", ".js", ".mjs", ".ts", ".tsx"]); +const SOURCE_EXTENSIONS = new Set([".cjs", ".cts", ".js", ".mjs", ".mts", ".ts", ".tsx"]); const SKIP_DIRS = new Set([".git", "coverage", "dist", "node_modules"]); const FORBIDDEN_DIRECTIVE = ["v8", "ignore"].join(" "); const FORBIDDEN_DIRECTIVE_PATTERN = new RegExp( @@ -91,7 +91,7 @@ function* walkSourceFiles(dir: string): Generator { } } -function isScannedSourcePath(filePath: string): boolean { +export function isScannedSourcePath(filePath: string): boolean { return ( filePath.length > 0 && SCAN_ROOTS.some((root) => filePath === root || filePath.startsWith(`${root}/`)) && diff --git a/scripts/checks/no-test-dist-imports.ts b/scripts/checks/no-test-dist-imports.mts similarity index 100% rename from scripts/checks/no-test-dist-imports.ts rename to scripts/checks/no-test-dist-imports.mts diff --git a/scripts/checks/no-unit-blocks-in-live-e2e.ts b/scripts/checks/no-unit-blocks-in-live-e2e.mts similarity index 100% rename from scripts/checks/no-unit-blocks-in-live-e2e.ts rename to scripts/checks/no-unit-blocks-in-live-e2e.mts diff --git a/scripts/checks/openshell-policy-mutation-read.ts b/scripts/checks/openshell-policy-mutation-read.mts similarity index 100% rename from scripts/checks/openshell-policy-mutation-read.ts rename to scripts/checks/openshell-policy-mutation-read.mts diff --git a/scripts/checks/run.ts b/scripts/checks/run.mts similarity index 83% rename from scripts/checks/run.ts rename to scripts/checks/run.mts index ee5138e6a4d..278e466dc8f 100644 --- a/scripts/checks/run.ts +++ b/scripts/checks/run.mts @@ -32,7 +32,7 @@ export const CHECKS: readonly CheckCommand[] = [ name: "direct-credential-env", command: TSX, args: [ - "scripts/checks/direct-credential-env.ts", + "scripts/checks/direct-credential-env.mts", "src/lib/onboard.ts", "src/lib/onboard/provider-key-bridge.ts", "src/lib/onboard/providers.ts", @@ -41,57 +41,57 @@ export const CHECKS: readonly CheckCommand[] = [ { name: "local-credential-helper-pin", command: TSX, - args: ["scripts/checks/local-credential-helper-pin.ts"], + args: ["scripts/checks/local-credential-helper-pin.mts"], }, { name: "hermes-light-skin-boundary", command: TSX, - args: ["scripts/checks/hermes-light-skin-boundary.ts"], + args: ["scripts/checks/hermes-light-skin-boundary.mts"], }, { name: "dependency-pins", command: TSX, - args: ["scripts/checks/dependency-pins.ts"], + args: ["scripts/checks/dependency-pins.mts"], }, { name: "no-coverage-ignore", command: TSX, - args: ["scripts/checks/no-coverage-ignore.ts"], + args: ["scripts/checks/no-coverage-ignore.mts"], }, { name: "openshell-policy-mutation-read", command: TSX, - args: ["scripts/checks/openshell-policy-mutation-read.ts"], + args: ["scripts/checks/openshell-policy-mutation-read.mts"], }, { name: "layer-import-boundaries", command: TSX, - args: ["scripts/checks/layer-import-boundaries.ts"], + args: ["scripts/checks/layer-import-boundaries.mts"], }, { name: "no-test-dist-imports", command: TSX, - args: ["scripts/checks/no-test-dist-imports.ts"], + args: ["scripts/checks/no-test-dist-imports.mts"], }, { name: "test-create-require-budget", command: TSX, - args: ["scripts/checks/test-create-require-budget.ts"], + args: ["scripts/checks/test-create-require-budget.mts"], }, { name: "vitest-project-overlap", command: TSX, - args: ["scripts/checks/vitest-project-overlap.ts"], + args: ["scripts/checks/vitest-project-overlap.mts"], }, { name: "test-title-style", command: TSX, - args: ["scripts/checks/test-title-style.ts"], + args: ["scripts/checks/test-title-style.mts"], }, { name: "no-unit-blocks-in-live-e2e", command: TSX, - args: ["scripts/checks/no-unit-blocks-in-live-e2e.ts"], + args: ["scripts/checks/no-unit-blocks-in-live-e2e.mts"], }, ]; diff --git a/scripts/checks/test-create-require-budget.ts b/scripts/checks/test-create-require-budget.mts similarity index 100% rename from scripts/checks/test-create-require-budget.ts rename to scripts/checks/test-create-require-budget.mts diff --git a/scripts/checks/test-title-style.ts b/scripts/checks/test-title-style.mts similarity index 100% rename from scripts/checks/test-title-style.ts rename to scripts/checks/test-title-style.mts diff --git a/scripts/checks/vitest-project-overlap.ts b/scripts/checks/vitest-project-overlap.mts similarity index 100% rename from scripts/checks/vitest-project-overlap.ts rename to scripts/checks/vitest-project-overlap.mts diff --git a/src/lib/messaging/manifest/types.test.ts b/src/lib/messaging/manifest/types.test.ts index 77b790c3f7f..400bf5ed531 100644 --- a/src/lib/messaging/manifest/types.test.ts +++ b/src/lib/messaging/manifest/types.test.ts @@ -292,6 +292,6 @@ describe("messaging manifest type contracts", () => { }); // Import-layer isolation for the production manifest modules is enforced by - // scripts/checks/layer-import-boundaries.ts. Keep this unit test focused on + // scripts/checks/layer-import-boundaries.mts. Keep this unit test focused on // manifest serialization and type contracts rather than walking source files. }); diff --git a/src/lib/security/credential-env.ts b/src/lib/security/credential-env.ts index afaf5813f32..3d9f7be7547 100644 --- a/src/lib/security/credential-env.ts +++ b/src/lib/security/credential-env.ts @@ -8,7 +8,7 @@ // separator-free provider parameters such as `clientSecret`, browser/session // material such as cookies, and connection strings cannot slip past the // validator. The standalone local credential helper and browser form embed this -// literal pattern; scripts/checks/local-credential-helper-pin.ts enforces exact +// literal pattern; scripts/checks/local-credential-helper-pin.mts enforces exact // parity because those reviewed artifacts cannot import this module at runtime. export const CREDENTIAL_SHAPED_NAME_PATTERN = /(?:^|[_-])(?:api[_-]?key|access[_-]?key|secret[_-]?key|auth[_-]?token|refresh[_-]?token|access[_-]?token|client[_-]?secret|private[_-]?key|pass[_-]?code|personal[_-]?access[_-]?token|connection[_-]?string|webhook(?:[_-]?url)?|key|secret|token|password|passwd|passcode|auth|authorization|credential|credentials|bearer|bearer[_-]?token|cookie|cookies|pat|private|privatekey|pin|webhookurl|dsn|connectionstring)(?:$|[_-])/i; diff --git a/test/checks-runner.test.ts b/test/checks-runner.test.ts index 6d28b487fee..053774321da 100644 --- a/test/checks-runner.test.ts +++ b/test/checks-runner.test.ts @@ -4,12 +4,12 @@ import type { SpawnSyncOptions } from "node:child_process"; import { describe, expect, it, vi } from "vitest"; -import { buildCheckSpawnInvocation, runChecks } from "../scripts/checks/run"; +import { buildCheckSpawnInvocation, runChecks } from "../scripts/checks/run.mts"; const sampleCheck = { name: "sample", command: "tsx.cmd", - args: ["scripts/checks/sample.ts"], + args: ["scripts/checks/sample.mts"], }; function successfulSpawn(): { status: number | null } { @@ -24,7 +24,7 @@ describe("checks runner", () => { }), ).toEqual({ command: "C:\\Windows\\System32\\cmd.exe", - args: ["/d", "/s", "/c", "tsx.cmd", "scripts/checks/sample.ts"], + args: ["/d", "/s", "/c", "tsx.cmd", "scripts/checks/sample.mts"], }); }); @@ -37,7 +37,7 @@ describe("checks runner", () => { it("keeps POSIX runner execution direct", () => { expect(buildCheckSpawnInvocation(sampleCheck, "linux")).toEqual({ command: "tsx.cmd", - args: ["scripts/checks/sample.ts"], + args: ["scripts/checks/sample.mts"], }); }); @@ -57,7 +57,7 @@ describe("checks runner", () => { expect(spawn).toHaveBeenCalledWith( "C:\\Windows\\System32\\cmd.exe", - ["/d", "/s", "/c", "tsx.cmd", "scripts/checks/sample.ts"], + ["/d", "/s", "/c", "tsx.cmd", "scripts/checks/sample.mts"], expect.objectContaining({ stdio: "inherit" }), ); expect(calls[0]?.shell).toBeUndefined(); @@ -72,7 +72,7 @@ describe("checks runner", () => { expect(spawn).toHaveBeenCalledWith( "tsx.cmd", - ["scripts/checks/sample.ts"], + ["scripts/checks/sample.mts"], expect.objectContaining({ stdio: "inherit" }), ); }); diff --git a/test/dependency-pins-check.test.ts b/test/dependency-pins-check.test.ts index 9599a54d1f9..67bbeadc575 100644 --- a/test/dependency-pins-check.test.ts +++ b/test/dependency-pins-check.test.ts @@ -7,7 +7,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; -import { verifyDependencyPins } from "../scripts/checks/dependency-pins"; +import { verifyDependencyPins } from "../scripts/checks/dependency-pins.mts"; const OPENSHELL_MIN = "1.2.3"; const OPENSHELL_MAX = "1.2.4"; diff --git a/test/e2e-mock-parity.test.ts b/test/e2e-mock-parity.test.ts index 0de972be19b..b2d607abdff 100644 --- a/test/e2e-mock-parity.test.ts +++ b/test/e2e-mock-parity.test.ts @@ -6,7 +6,7 @@ import { isMockParityRelevantSourceChange, type MockParityManifest, validateMockParity, -} from "../scripts/checks/e2e-mock-parity"; +} from "../scripts/checks/e2e-mock-parity.mts"; const live = "test/e2e/live/example.test.ts"; const fast = "test/e2e/support/example.test.ts"; diff --git a/test/hermes-light-skin-boundary.test.ts b/test/hermes-light-skin-boundary.test.ts new file mode 100644 index 00000000000..801c3172189 --- /dev/null +++ b/test/hermes-light-skin-boundary.test.ts @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import { checkHermesLightSkinBoundary } from "../scripts/checks/hermes-light-skin-boundary.mts"; + +function dockerfileWithVersion(version: string): string { + return ["FROM debian:bookworm-slim", `ARG HERMES_VERSION=${version}`, ""].join("\n"); +} + +describe("hermes light-skin boundary check", () => { + it("passes when the pinned version is reviewed", () => { + expect( + checkHermesLightSkinBoundary({ + dockerfileText: dockerfileWithVersion("v2026.7.1"), + reviewedVersions: ["v2026.6.19", "v2026.7.1"], + }), + ).toBeNull(); + }); + + it("fails when the pinned version has not been reviewed", () => { + const error = checkHermesLightSkinBoundary({ + dockerfileText: dockerfileWithVersion("v2026.8.1"), + reviewedVersions: ["v2026.6.19", "v2026.7.1"], + }); + + expect(error).toContain("needs re-review"); + expect(error).toContain("v2026.8.1"); + }); + + it("fails when the Dockerfile has no HERMES_VERSION arg", () => { + const error = checkHermesLightSkinBoundary({ + dockerfileText: "FROM debian:bookworm-slim\n", + reviewedVersions: ["v2026.7.1"], + }); + + expect(error).toContain("could not find ARG HERMES_VERSION"); + }); +}); diff --git a/test/layer-import-boundaries.test.ts b/test/layer-import-boundaries.test.ts index daec1164a9e..6a8eac03d7f 100644 --- a/test/layer-import-boundaries.test.ts +++ b/test/layer-import-boundaries.test.ts @@ -6,7 +6,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; -import { findLayerImportBoundaryViolations } from "../scripts/checks/layer-import-boundaries"; +import { findLayerImportBoundaryViolations } from "../scripts/checks/layer-import-boundaries.mts"; const REPO_ROOT = path.join(import.meta.dirname, ".."); let fixtureCounter = 0; diff --git a/test/local-credential-helper-pin.test.ts b/test/local-credential-helper-pin.test.ts index a2d66212772..07175bdc792 100644 --- a/test/local-credential-helper-pin.test.ts +++ b/test/local-credential-helper-pin.test.ts @@ -9,7 +9,7 @@ import { extractProcessControlRules, extractStringSet, verifyFieldSafetySourceParity, -} from "../scripts/checks/local-credential-helper-pin"; +} from "../scripts/checks/local-credential-helper-pin.mts"; const FUNCTION_NAME = "isBlocked"; const SET_NAME = "BLOCKED_NAMES"; diff --git a/test/no-coverage-ignore.test.ts b/test/no-coverage-ignore.test.ts index fe5d310cc5b..d8865c3258a 100644 --- a/test/no-coverage-ignore.test.ts +++ b/test/no-coverage-ignore.test.ts @@ -2,7 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it } from "vitest"; -import { findCoverageIgnoreDirectives } from "../scripts/checks/no-coverage-ignore"; +import { + findCoverageIgnoreDirectives, + isScannedSourcePath, +} from "../scripts/checks/no-coverage-ignore.mts"; const forbiddenDirective = ["v8", "ignore"].join(" "); @@ -29,3 +32,15 @@ describe("coverage ignore guard", () => { expect(findCoverageIgnoreDirectives(source, "src/example.ts")).toEqual([]); }); }); + +describe("scanned source path selection", () => { + it("scans .mts files under tracked roots", () => { + expect(isScannedSourcePath("scripts/checks/no-coverage-ignore.mts")).toBe(true); + expect(isScannedSourcePath("src/lib/example.mts")).toBe(true); + }); + + it("excludes non-source extensions and paths outside tracked roots", () => { + expect(isScannedSourcePath("scripts/checks/README.md")).toBe(false); + expect(isScannedSourcePath("docs/example.mts")).toBe(false); + }); +}); diff --git a/test/no-direct-credential-env.test.ts b/test/no-direct-credential-env.test.ts index 6312146724a..d559ec2c6e0 100644 --- a/test/no-direct-credential-env.test.ts +++ b/test/no-direct-credential-env.test.ts @@ -14,7 +14,7 @@ import { spawnSync } from "node:child_process"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { findDirectCredentialEnvReads } from "../scripts/checks/direct-credential-env"; +import { findDirectCredentialEnvReads } from "../scripts/checks/direct-credential-env.mts"; describe("direct credential env guard", () => { it.each([ @@ -96,7 +96,7 @@ describe("direct credential env guard", () => { "npx", [ "tsx", - "scripts/checks/direct-credential-env.ts", + "scripts/checks/direct-credential-env.mts", "src/lib/onboard.ts", "src/lib/onboard/provider-key-bridge.ts", "src/lib/onboard/providers.ts", diff --git a/test/no-unit-blocks-in-live-e2e.test.ts b/test/no-unit-blocks-in-live-e2e.test.ts index bb37f9d1197..8bdc4ca4519 100644 --- a/test/no-unit-blocks-in-live-e2e.test.ts +++ b/test/no-unit-blocks-in-live-e2e.test.ts @@ -3,7 +3,10 @@ import { describe, expect, it } from "vitest"; -import { findLiveUnitBlocks, formatViolations } from "../scripts/checks/no-unit-blocks-in-live-e2e"; +import { + findLiveUnitBlocks, + formatViolations, +} from "../scripts/checks/no-unit-blocks-in-live-e2e.mts"; const FILE = "test/e2e/live/example.test.ts"; diff --git a/test/policy-mutation-read-discovery.test.ts b/test/policy-mutation-read-discovery.test.ts index 6c02027145f..e1224195d79 100644 --- a/test/policy-mutation-read-discovery.test.ts +++ b/test/policy-mutation-read-discovery.test.ts @@ -10,7 +10,7 @@ import { describe, expect, it } from "vitest"; import { auditOpenShellPolicyMutationReads, discoverPolicyReadSites, -} from "../scripts/checks/openshell-policy-mutation-read"; +} from "../scripts/checks/openshell-policy-mutation-read.mts"; describe("OpenShell policy mutation read discovery", () => { it("discovers builder and direct policy reads in new production files", () => { diff --git a/test/pr-workflow-contract.test.ts b/test/pr-workflow-contract.test.ts index f17645f4d32..e1c77d862ea 100644 --- a/test/pr-workflow-contract.test.ts +++ b/test/pr-workflow-contract.test.ts @@ -981,7 +981,7 @@ describe("pull request and main workflow contracts", () => { expect(parityStep.run).toContain("head=HEAD^2"); expect(parityStep.run).toContain('base="$PUSH_BASE_SHA"'); expect(parityStep.run).toContain( - 'npx tsx scripts/checks/e2e-mock-parity.ts --base "$base" --head "$head"', + 'npx tsx scripts/checks/e2e-mock-parity.mts --base "$base" --head "$head"', ); const trustedCapabilityProbe = requiredWorkflowStep( diff --git a/test/test-boundary-guards.test.ts b/test/test-boundary-guards.test.ts index 3237fbf5b9d..65de61e42cc 100644 --- a/test/test-boundary-guards.test.ts +++ b/test/test-boundary-guards.test.ts @@ -12,7 +12,7 @@ import { findFastProjectTransitiveViolations, isFastProjectTestPath, isScannedTestPath, -} from "../scripts/checks/no-test-dist-imports"; +} from "../scripts/checks/no-test-dist-imports.mts"; import { discoverVitestCandidates, EXPECTED_VITEST_PROJECTS, @@ -22,7 +22,7 @@ import { parseProjectListing, parseProjectRoster, resolveVitestInvocation, -} from "../scripts/checks/vitest-project-overlap"; +} from "../scripts/checks/vitest-project-overlap.mts"; const REPO_ROOT = path.join(import.meta.dirname, ".."); const SOURCE_RUNTIME = path.join(REPO_ROOT, "test", "helpers", "onboard-script-mocks.cjs"); diff --git a/test/test-create-require-budget.test.ts b/test/test-create-require-budget.test.ts index 5b4f0d67a5b..749bd292bf0 100644 --- a/test/test-create-require-budget.test.ts +++ b/test/test-create-require-budget.test.ts @@ -12,7 +12,7 @@ import { collectTestSupportCreateRequireSources, containsCreateRequireIdentifier, createRequireBudgetFailure, -} from "../scripts/checks/test-create-require-budget"; +} from "../scripts/checks/test-create-require-budget.mts"; const tempDirs = new Set(); diff --git a/test/test-title-style.test.ts b/test/test-title-style.test.ts index 81b6f35acda..9b6b6395906 100644 --- a/test/test-title-style.test.ts +++ b/test/test-title-style.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; -import { scanTestTitleStyle } from "../scripts/checks/test-title-style"; +import { scanTestTitleStyle } from "../scripts/checks/test-title-style.mts"; function rulesFor(source: string): string[] { return scanTestTitleStyle("test/virtual-title-style.test.ts", source).map(