Skip to content
Open
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
29 changes: 29 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Line endings are LF everywhere, whatever the platform's `core.autocrlf` says.
#
# Without this file, a Windows checkout with git's default `core.autocrlf=true` rewrites every
# text file to CRLF, and the guards that compare committed bytes then fail on a clean clone:
# LICENSE/NOTICE are pinned by length and SHA-256 (license-files.test.ts), public/_headers,
# vercel.json and the index.html CSP meta are compared as text (headers-policy.test.ts), and
# docs/ARCHITECTURE.md is compared against a fresh render (reference-doc.test.ts). Tooling that
# reads the same files (`generate-headers`, `generate-reference`, `stamp-build`) is line-ending
# agnostic because of this declaration rather than because every caller normalizes by hand.
* text=auto eol=lf

# Third-party licence text is copied verbatim from the shipped packages by `npm run legal:licenses`,
# and several upstream LICENSE files are CRLF. Normalising them would make the committed copies
# differ from what the extractor produces, so they are excluded from text conversion.
licenses/** -text

# Binary assets: never inspected, never converted.
*.png binary
*.jpg binary
*.jpeg binary
*.webp binary
*.gif binary
*.ico binary
*.woff binary
*.woff2 binary
*.onnx binary
*.bin binary
*.gz binary
*.wasm binary
70 changes: 70 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Type-check, test, drift-check and build on every push to the public branch and every pull request.
#
# Windows is in the matrix deliberately. Two shipped guards fail ONLY on a Windows checkout:
#
# * the byte-comparison guards (LICENSE length + SHA-256, public/_headers, vercel.json, the
# index.html CSP meta, docs/ARCHITECTURE.md against a fresh render) compare committed text, and
# a checkout with git's default `core.autocrlf=true` rewrote every file to CRLF before
# `.gitattributes` pinned `eol=lf`. The guards were red on a clean clone and nothing said so;
# * `kit/operation-purity.test.ts` exempts three macro bindings by suffix, and `path.join` gives
# them backslash separators on Windows, so the exemption silently stopped applying.
#
# A Linux-only job cannot observe either, which is how both reached a release.
name: CI

on:
push:
branches: [main]
pull_request:

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
verify:
name: verify (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Set up Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
# The repository's own floor, not a copy of it: `.node-version` is what a local clone reads.
node-version-file: .node-version
cache: npm

- name: Install
run: npm ci

- name: Type-check
run: npm run lint

- name: Test
run: npm run test:run

- name: Build
run: npm run build

# Generated artefacts carry their own drift guards, and they read the same committed files the
# tests do — so they run after the build has produced `dist/` for the license/bundle closure
# comparison. `legal:validate` is the release-mode config check `npm run build:release` leads
# with; running it here means a broken legal config fails on the pull request rather than at
# publish time.
- name: Generated-artefact drift
shell: bash
run: |
npm run stamp:check
npm run docs:check
npm run legal:licenses:check
npm run legal:headers:check
npm run legal:validate
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ tsconfig.tsbuildinfo
npm-debug.log*
coverage/

# Build-report side product written into the repo root by security/bundle-report-plugin
.bundle-packages.json

# OS files
.DS_Store
Thumbs.db
Expand Down
2 changes: 1 addition & 1 deletion scripts/build-legal-pages.mts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ async function main(): Promise<void> {
);
}

writeAll(distDir, LEGAL, mode);
await writeAll(distDir, LEGAL, mode);

// Cloudflare reads `_redirects`; the Chinese edge serves slashed page paths and receives none.
if (LEGAL.canonicalOrigin === DEPLOY_TARGETS.global.canonicalOrigin) {
Expand Down
17 changes: 11 additions & 6 deletions scripts/generate-headers.mts
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,16 @@ async function main(): Promise<void> {
const nextVercel = stringifyVercelJson(toVercelJson(existingVercel, HEADERS_POLICY));
writeIfChanged(vercelPath, nextVercel, drift, check);

// Deployment-operator copy of the ESA headers.
// Deployment-operator copy of the ESA headers. It lives under docs/internal/, a tree the public
// snapshot does not carry, so the guard is the DIRECTORY's presence: where the internal tree
// exists the doc is written and checked (deleting it is drift); where it does not, the file is
// not this checkout's to assert on and reporting it would fail every run.
const esaPath = join(rootDir, 'docs', 'internal', 'deployment', 'esa-headers.md');
writeIfChanged(esaPath, toEsaDoc(HEADERS_POLICY, {
canonicalOrigin: DEPLOY_TARGETS.cn.canonicalOrigin, legacyOrigins: DEPLOY_TARGETS.cn.legacyOrigins,
}), drift, check);
if (existsSync(join(rootDir, 'docs', 'internal'))) {
writeIfChanged(esaPath, toEsaDoc(HEADERS_POLICY, {
canonicalOrigin: DEPLOY_TARGETS.cn.canonicalOrigin, legacyOrigins: DEPLOY_TARGETS.cn.legacyOrigins,
}), drift, check);
}

// index.html — surgical CSP <meta> (+ comment) rewrite only.
const indexPath = join(rootDir, 'index.html');
Expand All @@ -66,10 +71,10 @@ async function main(): Promise<void> {
for (const p of drift.paths) console.error(` ${p}`);
process.exitCode = 1;
} else {
console.log('[generate-headers] up to date (public/_headers, vercel.json, docs/internal/deployment/esa-headers.md, index.html).');
console.log('[generate-headers] up to date (public/_headers, vercel.json, index.html, and the ESA doc where docs/internal/ exists).');
}
} else {
console.log('[generate-headers] wrote public/_headers, vercel.json, docs/internal/deployment/esa-headers.md; rewrote index.html CSP meta.');
console.log('[generate-headers] wrote public/_headers, vercel.json; rewrote index.html CSP meta (plus the ESA doc where docs/internal/ exists).');
}
}

Expand Down
14 changes: 11 additions & 3 deletions scripts/legal-pages-core.mts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@
*/

// @ts-ignore - node:fs is untyped here (no @types/node)
import { cpSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
// The async `cp` rather than `cpSync`: on Windows the SYNCHRONOUS recursive copy aborts the process
// (0xC0000409) when the SOURCE path contains any non-ASCII character, so `npm run build` could not
// complete from a checkout under e.g. `E:\项目\…` — it exited non-zero with an empty `dist/licenses`.
// The async implementation is unaffected. See the note in docs/ARCHITECTURE.md.
// @ts-ignore - node:fs/promises is untyped here (no @types/node)
import { cp } from 'node:fs/promises';
// @ts-ignore - node:path is untyped here (no @types/node)
import { join } from 'node:path';

Expand Down Expand Up @@ -311,8 +317,10 @@ function writeTextFile(distDir: string, relPath: string, contents: string): void
/**
* Writes all static legal artifacts to `distDir`.
* Release mode rejects configuration problems; unresolved content tokens always fail; injected `now` makes security.txt deterministic.
*
* ASYNC because the license copy uses the asynchronous recursive copy — see the import note above.
*/
export function writeAll(distDir: string, cfg: LegalConfig, mode: 'release' | 'dev', now: Date = new Date()): void {
export async function writeAll(distDir: string, cfg: LegalConfig, mode: 'release' | 'dev', now: Date = new Date()): Promise<void> {
const problems = validateLegalConfig(cfg, mode);
if (problems.length > 0) {
if (mode === 'release') {
Expand Down Expand Up @@ -342,7 +350,7 @@ export function writeAll(distDir: string, cfg: LegalConfig, mode: 'release' | 'd
const licensesDest = join(distDir, 'licenses');
if (existsSync(licensesSrc)) {
mkdirSync(licensesDest, { recursive: true });
cpSync(licensesSrc, licensesDest, { recursive: true });
await cp(licensesSrc, licensesDest, { recursive: true });
} else {
console.warn(`[legal-pages] licenses/ not found at ${licensesSrc} — run "npm run legal:licenses" first.`);
}
Expand Down
10 changes: 9 additions & 1 deletion src/__tests__/canvas/object-remove-cost.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
* at zero, however many decorations sit on the map.
*/
import './_pixi-env';
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import { ObjectLayer } from '../../canvas/map2d/layers/object-layer';
import { type PlacedObject } from '../../core/model/types';
import { getCatalogItem, registerCatalogItem } from '../../state/catalog';
Expand All @@ -36,6 +36,14 @@ function instrumentLodMap(layer: ObjectLayer): { map: Map<string, unknown>; walk
return { map, walks: () => walks };
}

/** Building thousands of sprites through Pixi inside jsdom dominates these tests; the assertions
* themselves only count full-collection walks. Measured on an idle machine the three cases take
* 117ms, 823ms and 396ms, so the default 5s is not the problem — a LOADED worker is, where all
* three timed out while the whole suite ran in parallel. Stated rather than inherited, at the
* budget the repository's other heavy suites already use (`vi.setConfig({ testTimeout: 60_000 })`
* in the generation suites). */
vi.setConfig({ testTimeout: 60_000 });

describe('ObjectLayer.removeObjects costs the removal, not the map', () => {
it('drops exactly the removed ids from the LOD-tracked set', () => {
const layer = new ObjectLayer();
Expand Down
28 changes: 27 additions & 1 deletion src/__tests__/io/compression-compat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,38 @@ it.each([CompressionMethod.Deflate, CompressionMethod.Gzip])('rejects a corrupt
expect(() => decompress(packed, method, limits.maxBytes)).toThrow();
});

it('rejects concatenated gzip members like native streams and preserves output limits', async () => {
it('bounds concatenated gzip members and preserves output limits', async () => {
const packed = compress(bytes, CompressionMethod.Gzip);
const pair = new Uint8Array(packed.length * 2);
pair.set(packed); pair.set(packed, packed.length);

// Our own decoder refuses multi-member input outright.
expect(() => decompress(pair, CompressionMethod.Gzip, limits.maxBytes)).toThrow();

// The PLATFORM decoder answers differently per runtime, and both answers are legal: RFC 1952 makes
// a gzip file a SEQUENCE of members, so Node's zlib-backed stream reads the whole sequence and
// returns both members (measured: Node 22.23.1 returns 2x the payload), while the browser's stream
// stops at the first member and errors on the trailing bytes. This suite runs under Node, so that
// is the expectation pinned here — and the block below drives the no-platform path, which is the
// one a browser without Compression Streams takes, and pins its answer too.
const both = await inflate(pair, CompressionMethod.Gzip, limits);
expect(both.length).toBe(bytes.length * 2);
expect(both.subarray(0, bytes.length)).toEqual(bytes);
expect(both.subarray(bytes.length)).toEqual(bytes);

// No platform streams: the fallback decoder answers, and it must refuse rather than hand back a
// partial buffer.
vi.stubGlobal('CompressionStream', undefined);
vi.stubGlobal('DecompressionStream', undefined);
await expect(inflate(pair, CompressionMethod.Gzip, limits)).rejects.toThrow();
vi.unstubAllGlobals();

// A payload whose expansion passes the caller's cap is refused instead of buffered. One member
// large enough to trip it, so the check holds on either path.
const oversized = compress(new Uint8Array(limits.maxBytes + 1), CompressionMethod.Gzip);
await expect(inflate(oversized, CompressionMethod.Gzip, limits)).rejects.toThrow();

// The single-member path is unaffected, and the absolute limit still trips.
expect(() => decompress(packed, CompressionMethod.Gzip, 100)).toThrow('safety limit');
});

Expand Down
8 changes: 7 additions & 1 deletion src/__tests__/kit/generate-operation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* One generate implementation, three callers. This is the test that fails if a second one appears:
* two contexts, one seed, and the results have to be indistinguishable down to the provenance.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { generateMap, generateCandidate, clearGenerated } from '../../kit/operations';
import { currentKit } from '../../kit/context';
import { newMap } from '../../kit/operations';
Expand All @@ -13,6 +13,12 @@ import { stableSerialize } from './_stable-serialize';
import { CommandType, TerrainType } from '../../core/model/types';
import type { GenerateConfig, GridState, MacroCoord, PlacedObject } from '../../core/model/types';

// A full island generation is the expensive operation in this file and it runs 21 of them, several
// as a pair whose POINT is that both runs produce identical bytes. Measured: one case here takes
// 584ms-1246ms on an idle machine, so it is a pair on a loaded worker that passes the default 5s
// rather than a single run. 60s is the budget the repository's other generation suites already use.
vi.setConfig({ testTimeout: 60_000 });

const config = (seed: number): GenerateConfig => ({
algorithm: 'designed', mode: 'mixed', corridorWidth: 1, maxElevation: 4, seed, region: null,
richness: 1,
Expand Down
11 changes: 8 additions & 3 deletions src/__tests__/kit/operation-purity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,13 @@ function filesUnder(dir: string): string[] {
return out;
}

/** `filesUnder` builds paths with `path.join`, so the separator is the platform's. Report and
* compare on the POSIX form — otherwise a Windows checkout both fails to exempt the bindings and
* prints backslash paths in the failure message. */
const toPosix = (file: string): string => file.replace(/\\/g, '/');

/** The pointer binding receives ToolContext and reports outcomes; macro bodies stay silent. */
const isToolBinding = (file: string): boolean => ['macros/macro-tool.ts', 'macros/drag-tool.ts', 'macros/spray-tool.ts'].some(binding => file.endsWith(binding));
const isToolBinding = (file: string): boolean => ['macros/macro-tool.ts', 'macros/drag-tool.ts', 'macros/spray-tool.ts'].some(binding => toPosix(file).endsWith(binding));

describe('operations stay silent', () => {
it('never narrates its own result', () => {
Expand All @@ -48,7 +53,7 @@ describe('operations stay silent', () => {
if (isToolBinding(file)) continue;
const text = readFileSync(file, 'utf8');
for (const { pattern, why } of FORBIDDEN) {
if (pattern.test(text)) offenders.push(`${relative(resolve(__dirname, '../..'), file)} ${why}`);
if (pattern.test(text)) offenders.push(`${toPosix(relative(resolve(__dirname, '../..'), file))} ${why}`);
}
}
}
Expand All @@ -63,7 +68,7 @@ describe('operations stay silent', () => {
for (const file of filesUnder(root)) {
if (isToolBinding(file)) continue;
const text = readFileSync(file, 'utf8');
if (/\buseEditorStore\b/.test(text)) offenders.push(relative(resolve(__dirname, '../..'), file));
if (/\buseEditorStore\b/.test(text)) offenders.push(toPosix(relative(resolve(__dirname, '../..'), file)));
}
}
expect(offenders).toEqual([]);
Expand Down
Loading