Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions .github/workflows/native-platforms.yml
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,8 @@ jobs:
if: >-
needs.scope.outputs.selection == 'full' &&
inputs.ios_only == false
runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }}
# Keep this platform prerequisite out of the local queue during CI/Integration bursts.
runs-on: ubuntu-24.04
timeout-minutes: 30
env:
TN_CI_SHA: ${{ needs.scope.outputs.candidate_sha }}
Expand Down Expand Up @@ -171,7 +172,8 @@ jobs:
name: Android V8 source payload
needs: scope
if: needs.scope.outputs.selection == 'full' && inputs.ios_only != true
runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }}
# Keep this platform prerequisite out of the local queue during CI/Integration bursts.
runs-on: ubuntu-24.04
# 180-minute build ceiling plus 30 minutes for actions/cache/save, per the action's note.
timeout-minutes: 210
outputs:
Expand All @@ -193,8 +195,8 @@ jobs:
- uses: android-actions/setup-android@v4
with:
packages: platform-tools
# No Actions cache for the NDK: the runner image already carries it (this step takes two
# seconds on a miss), while saving it took 90 seconds and a GB-scale share of the cache budget.
# Install the pinned NDK on the selected runner. Caching it previously took 90 seconds
# and a GB-scale share of the cache budget.
- name: Install and select the pinned Android NDK
run: |
set -euo pipefail
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ pnpm native:build # opt-in; downloads deps, compiles th
pnpm native:verify:desktop # 300 native frames + a non-blank screenshot
```

**CI.** The pre-push hook runs `pnpm ci:fast` drift checks only; it proves no runtime behaviour. `pnpm ci:local --full` runs the full board; `pnpm ci:local --affected --base origin/develop --target develop` runs what CI would select (dirty or unknown inputs fall back to full). A change touching only Markdown that no fixture consumes (not `AGENTS.md`/`CLAUDE.md`) runs no product CI job. PRs and merge groups into develop use the complete candidate diff: CI-only changes run CI contracts, exact template changes run scaffolder contracts and affected templates, and shared/unknown executable inputs keep exhaustive coverage. Main qualification and scheduled audits remain exhaustive. Template scenarios run once per kit; selective changes run one representative golden journey and full qualification retains both starter and platformer journeys. `ci-required` rejects missing or failed selected checks; retained contexts include `supply-chain`, `template-nonvisual`, `golden-path`, and native `desktop-parity` when applicable. Native host/platform tests run for native or shared runtime consumers, main qualification and unresolved scopes. Unsafe matrix/reusable verdict reuse stays disabled until authoritative complete coverage and per-leg routing exist. A new candidate runs fresh tests; only exact-run immutable build artifacts are shared. Details: `docs/PRDs/CI/PRD-483-impact-driven-ci-without-repeated-template-setup.md` and `docs/PRDs/done/PRD-373-selective-ci-and-develop-promotion.md`.
**CI.** The pre-push hook runs `pnpm ci:fast` drift checks only; it proves no runtime behaviour. `pnpm ci:local --full` runs the full board; `pnpm ci:local --affected --base origin/develop --target develop` runs what CI would select (dirty or unknown inputs fall back to full). A change touching only Markdown that no fixture consumes (not `AGENTS.md`/`CLAUDE.md`) runs no product CI job. PRs and merge groups into develop use the complete candidate diff: CI-only changes run CI contracts, exact template changes run scaffolder contracts and affected templates, and shared executable inputs keep the full safety board with starter as the default template smoke plus explicitly changed runtime/scenario kits. Template instruction and Markdown changes retain their contract lanes without adding runtime kits. Unknown executable inputs, main qualification and scheduled audits remain exhaustive. Template scenarios run once per selected kit; develop changes run one representative golden journey and full qualification retains both starter and platformer journeys. `ci-required` rejects missing or failed selected checks; retained contexts include `supply-chain`, `template-nonvisual`, `golden-path`, and native `desktop-parity` when applicable. Native host/platform tests run for native or shared runtime consumers, main qualification and unresolved scopes. Unsafe matrix/reusable verdict reuse stays disabled until authoritative complete coverage and per-leg routing exist. A new candidate runs fresh tests; only exact-run immutable build artifacts are shared. Details: `docs/PRDs/CI/PRD-483-impact-driven-ci-without-repeated-template-setup.md` and `docs/PRDs/done/PRD-373-selective-ci-and-develop-promotion.md`.
Registry commands read auth from `~/.npmrc`; pass a checkout-local untracked `.npmrc` with `npm --userconfig .npmrc <command>`. Never print either file.

TypeScript 5.9 `strict`, **ESM only**; relative imports end in `.js`. Versions come from the `catalog:` in `pnpm-workspace.yaml`, templates excepted. Biome formats — never hand-format. Interfaces are `I`-prefixed; classes and type aliases are not. Unit tests: `<package>/__tests__/*.spec.ts`, vitest, node environment, DOM and GPU stubbed.
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ pnpm native:build # opt-in; downloads deps, compiles th
pnpm native:verify:desktop # 300 native frames + a non-blank screenshot
```

**CI.** The pre-push hook runs `pnpm ci:fast` drift checks only; it proves no runtime behaviour. `pnpm ci:local --full` runs the full board; `pnpm ci:local --affected --base origin/develop --target develop` runs what CI would select (dirty or unknown inputs fall back to full). A change touching only Markdown that no fixture consumes (not `AGENTS.md`/`CLAUDE.md`) runs no product CI job. PRs and merge groups into develop use the complete candidate diff: CI-only changes run CI contracts, exact template changes run scaffolder contracts and affected templates, and shared/unknown executable inputs keep exhaustive coverage. Main qualification and scheduled audits remain exhaustive. Template scenarios run once per kit; selective changes run one representative golden journey and full qualification retains both starter and platformer journeys. `ci-required` rejects missing or failed selected checks; retained contexts include `supply-chain`, `template-nonvisual`, `golden-path`, and native `desktop-parity` when applicable. Native host/platform tests run for native or shared runtime consumers, main qualification and unresolved scopes. Unsafe matrix/reusable verdict reuse stays disabled until authoritative complete coverage and per-leg routing exist. A new candidate runs fresh tests; only exact-run immutable build artifacts are shared. Details: `docs/PRDs/CI/PRD-483-impact-driven-ci-without-repeated-template-setup.md` and `docs/PRDs/done/PRD-373-selective-ci-and-develop-promotion.md`.
**CI.** The pre-push hook runs `pnpm ci:fast` drift checks only; it proves no runtime behaviour. `pnpm ci:local --full` runs the full board; `pnpm ci:local --affected --base origin/develop --target develop` runs what CI would select (dirty or unknown inputs fall back to full). A change touching only Markdown that no fixture consumes (not `AGENTS.md`/`CLAUDE.md`) runs no product CI job. PRs and merge groups into develop use the complete candidate diff: CI-only changes run CI contracts, exact template changes run scaffolder contracts and affected templates, and shared executable inputs keep the full safety board with starter as the default template smoke plus explicitly changed runtime/scenario kits. Template instruction and Markdown changes retain their contract lanes without adding runtime kits. Unknown executable inputs, main qualification and scheduled audits remain exhaustive. Template scenarios run once per selected kit; develop changes run one representative golden journey and full qualification retains both starter and platformer journeys. `ci-required` rejects missing or failed selected checks; retained contexts include `supply-chain`, `template-nonvisual`, `golden-path`, and native `desktop-parity` when applicable. Native host/platform tests run for native or shared runtime consumers, main qualification and unresolved scopes. Unsafe matrix/reusable verdict reuse stays disabled until authoritative complete coverage and per-leg routing exist. A new candidate runs fresh tests; only exact-run immutable build artifacts are shared. Details: `docs/PRDs/CI/PRD-483-impact-driven-ci-without-repeated-template-setup.md` and `docs/PRDs/done/PRD-373-selective-ci-and-develop-promotion.md`.
Registry commands read auth from `~/.npmrc`; pass a checkout-local untracked `.npmrc` with `npm --userconfig .npmrc <command>`. Never print either file.

TypeScript 5.9 `strict`, **ESM only**; relative imports end in `.js`. Versions come from the `catalog:` in `pnpm-workspace.yaml`, templates excepted. Biome formats — never hand-format. Interfaces are `I`-prefixed; classes and type aliases are not. Unit tests: `<package>/__tests__/*.spec.ts`, vitest, node environment, DOM and GPU stubbed.
Expand Down
221 changes: 216 additions & 5 deletions scripts/__tests__/ci-integration-scope.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import { spawnSync } from "node:child_process";
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import path from "node:path";
import ts from "typescript";
import { describe, expect, it } from "vitest";
import { makeTempDirSync } from "../../test-support/temp-dir.js";
const url = new URL("../ci-integration-scope.mjs", import.meta.url).href;
const { integrationSelection, integrationGitSelection } = await import(url);
const {
integrationSelection,
integrationGitSelection,
INDEPENDENT_CORE_MODULES,
INDEPENDENT_PACKAGES,
INDEPENDENT_PROOFS,
} = await import(url);
const workflow = readFileSync(
new URL("../../.github/workflows/integration.yml", import.meta.url),
"utf8",
Expand Down Expand Up @@ -342,11 +349,11 @@ describe("fluid runtime independence", () => {
expect(lanes["fluid-particles"]).toBe(true);
});
it.each(["packages/core/src/renderer.ts", "packages/core/src/render/chain.ts"])(
"does not run fluid proofs for unrelated core render source: %s",
"retains fluid consumers for shared renderer and observation source: %s",
(file) => {
const lanes = select(workflow, [file]);
expect(lanes["fluid-native"]).toBe(false);
expect(lanes["fluid-particles"]).toBe(false);
expect(lanes["fluid-native"]).toBe(true);
expect(lanes["fluid-particles"]).toBe(true);
},
);
it("fails closed for an unknown executable harness", () => {
Expand Down Expand Up @@ -446,3 +453,207 @@ describe("fluid CI observer ownership", () => {
},
);
});

describe("reviewed producer and independent feature boundaries", () => {
it("keeps fluid for the shared game loop and core build config", () => {
for (const file of [
"packages/core/src/game.ts",
"packages/core/src/clustered-mesh.ts",
"packages/core/tsup.config.ts",
"packages/core/patches/three.patch",
]) {
const lanes = select(workflow, [file]);
expect(lanes["fluid-particles"], file).toBe(true);
expect(lanes["fluid-native"], file).toBe(true);
expect(lanes.csg, file).toBe(false);
expect(lanes.ik, file).toBe(false);
expect(lanes.vegetation, file).toBe(false);
}
});
it("does not select fluid for independent terrain or animation modules", () => {
for (const file of [
"packages/core/src/terrain-jobs-worker.ts",
"packages/core/src/terrain-jobs.ts",
"packages/core/src/world-tiles.ts",
"packages/core/src/animation.ts",
"scripts/verify-animation-reversal.ts",
"scripts/temporal-aa-quality.ts",
]) {
const lanes = select(workflow, [file]);
expect(lanes["fluid-particles"], file).toBe(false);
expect(lanes["fluid-native"], file).toBe(false);
}
});
it("still selects fluid when an independent change shares a producer configuration delta", () => {
const lanes = select(workflow, [
"packages/core/src/terrain-jobs-worker.ts",
"packages/core/tsup.config.ts",
]);
expect(lanes["fluid-particles"]).toBe(true);
expect(lanes["fluid-native"]).toBe(true);
expect(lanes.csg).toBe(false);
});
it("retains unknown executable and scheduling fallback", () => {
expect(select(workflow, ["scripts/unclassified.ts"])).toEqual(all);
expect(select(workflow, ["packages/unknown/src/runtime.ts"])).toEqual(all);
});
});

const dependencyRoot = path.resolve(import.meta.dirname, "../..");
function runtimeSource(filename: string): string {
const source = filename.replace("/dist/", "/src/");
const candidates = [
source.replace(/\.js$/u, ".ts"),
`${source}.ts`,
source,
`${source}/index.ts`,
];
const resolved = candidates.find(
(candidate) => existsSync(candidate) && statSync(candidate).isFile(),
);
if (!resolved) throw new Error(`Unresolved fluid runtime source: ${filename}`);
return resolved;
}
function moduleEdges(file: string): { module: string; names: string[] }[] {
const source = ts.createSourceFile(
file,
readFileSync(file, "utf8"),
ts.ScriptTarget.Latest,
true,
);
const edges: { module: string; names: string[] }[] = [];
const visit = (node: ts.Node) => {
if (
ts.isImportDeclaration(node) &&
!node.importClause?.isTypeOnly &&
ts.isStringLiteral(node.moduleSpecifier)
) {
const bindings = node.importClause?.namedBindings;
const names =
bindings && ts.isNamedImports(bindings)
? bindings.elements
.filter((entry) => !entry.isTypeOnly)
.map((entry) => (entry.propertyName ?? entry.name).text)
: ["*"];
if (names.length) edges.push({ module: node.moduleSpecifier.text, names });
}
if (
ts.isExportDeclaration(node) &&
!node.isTypeOnly &&
node.moduleSpecifier &&
ts.isStringLiteral(node.moduleSpecifier)
)
edges.push({ module: node.moduleSpecifier.text, names: ["*"] });
if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) {
const argument = node.arguments[0];
if (!argument || !ts.isStringLiteral(argument))
throw new Error(`Unresolved runtime import in ${file}`);
edges.push({ module: argument.text, names: ["*"] });
}
ts.forEachChild(node, visit);
};
visit(source);
return edges;
}
function workspaceTargets(module: string, names: string[]): string[] {
const match = /^@threenative\/([^/]+)(?:\/(.*))?$/u.exec(module);
if (!match?.[1]) return [];
const entry = runtimeSource(
path.join(dependencyRoot, "packages", match[1], "src", match[2] ?? "index.ts"),
);
if (names.includes("*")) return [entry];
// Resolve the public named exports, instead of pretending an unused barrel export is exercised.
const source = ts.createSourceFile(
entry,
readFileSync(entry, "utf8"),
ts.ScriptTarget.Latest,
true,
);
const exports = source.statements
.filter(ts.isExportDeclaration)
.filter(
(node) =>
!node.isTypeOnly && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier),
);
const targets = new Set<string>();
for (const name of names) {
const exported = exports.find(
(node) =>
node.exportClause &&
ts.isNamedExports(node.exportClause) &&
node.exportClause.elements.some(
(element) => !element.isTypeOnly && element.name.text === name,
),
);
if (exported?.moduleSpecifier && ts.isStringLiteral(exported.moduleSpecifier))
targets.add(runtimeSource(path.resolve(path.dirname(entry), exported.moduleSpecifier.text)));
else targets.add(entry);
}
return [...targets];
}
function fluidRuntimeClosure(extra: { module: string; names: string[] }[] = []): Set<string> {
const pending = [
"examples/prd476-fluid-particles/src/main.ts",
"examples/prd476-fluid-particles/src/collision-proof.ts",
"scripts/verify-fluid-collision.ts",
"scripts/verify-fluid-consumers.ts",
"scripts/verify-fluid-collision-native.ts",
].map((file) => path.join(dependencyRoot, file));
pending.push(...extra.flatMap(({ module, names }) => workspaceTargets(module, names)));
const seen = new Set<string>();
while (pending.length) {
const file = pending.pop() as string;
if (seen.has(file)) continue;
seen.add(file);
for (const edge of moduleEdges(file)) {
if (edge.module.startsWith("."))
pending.push(runtimeSource(path.resolve(path.dirname(file), edge.module)));
else pending.push(...workspaceTargets(edge.module, edge.names));
}
}
return seen;
}
it("guards independent modules against the actual fluid fixture/harness runtime graph", () => {
const seen = fluidRuntimeClosure();
expect(
[...seen].filter((file) => INDEPENDENT_PROOFS.test(path.relative(dependencyRoot, file))),
).toEqual([]);
for (const name of INDEPENDENT_PACKAGES)
expect(
[...seen].some((file) => file.includes(`/packages/${name}/src/`)),
name,
).toBe(false);
for (const name of INDEPENDENT_CORE_MODULES)
expect(seen.has(path.join(dependencyRoot, `packages/core/src/${name}.ts`)), name).toBe(false);
for (const name of ["clustered-mesh", "renderer", "render/chain", "gpu-readback"])
expect(seen.has(path.join(dependencyRoot, `packages/core/src/${name}.ts`)), name).toBe(true);
});
it("detects a new fixture dependency through a named public export", () => {
const seen = fluidRuntimeClosure([{ module: "@threenative/core", names: ["AnimationPlayer"] }]);
expect(seen.has(path.join(dependencyRoot, "packages/core/src/animation.ts"))).toBe(true);
});
it("rejects an unresolved new workspace-package import instead of accepting an exclusion", () => {
expect(() =>
fluidRuntimeClosure([{ module: "@threenative/unclassified", names: ["newDependency"] }]),
).toThrow("Unresolved fluid runtime source");
});

it("fails closed when a shared producer reaches a newly added unclassified consumer", () => {
const added = [
workflow
.replace(
" outputs:\n",
" outputs:\n new-core: ${{ steps.filter.outputs.new-core }}\n",
)
.replace(
" TN_LANE_FILTERS: |\n",
" TN_LANE_FILTERS: |\n new-core ^examples/new-core/.*$\n",
),
"\n new-core:\n needs: paths\n if: ${{ needs.paths.outputs.new-core == 'true' && !github.event.pull_request.draft }}\n runs-on: ubuntu-24.04\n steps:\n - run: pnpm build\n",
].join("");
expect(select(added, [".github/workflows/integration.yml"])).toEqual({
...none,
"new-core": true,
});
expect(select(added, ["packages/core/tsup.config.ts"])).toEqual({ ...all, "new-core": true });
});
Loading
Loading