feedback5: merge W5W — DESIGN §25 catch-up (sleep port, board tap, packaging, the owner's 2026-10-06 answers, i18n phase 2, the 自选 voices, the summon-model gap) and the DESIGN split: docs/DESIGN.md is the index, current rules in docs/design/*.md, per-release history in docs/history/*.md, section numbers unchanged (the parts reassemble the old file byte for byte; test/docs-paths.test.js guards the index and every DESIGN §N citation); test/package.test.js builds its fake home path at run time

This commit is contained in:
sganggs committed 2026-10-06 19:22:14 +08:00
commit 8ce6d280bd
30 files changed
+3556 -3096

No files matched your search

+5 -2
View File
@@ -1,4 +1,5 @@
// Documentation ⇄ code consistency (docs/DESIGN.md, DATA.md, META.md, SIM.md, README.md, DEPLOY.md, PLAYING.md).
// Documentation ⇄ code consistency (docs/DESIGN.md with docs/design/ and docs/history/, DATA.md, META.md, SIM.md, README.md,
// DEPLOY.md, PLAYING.md).
// Every rule the final documentation sweep corrected is checked twice: the code still behaves as the docs now say, and
// the stale wording does not come back. Topics: combat / boss clocks in REAL seconds (overtime after 150 real s,
// m.public.overtimeAt), 联防 helper order (unite.helperOrder, research 08 §5), boss results handed over instead of a
@@ -48,6 +49,7 @@ import { SPINE_EVICT_DELAY_MS, SPINE_QUIET_DELAY_MS } from '../public/js/assets.
import { RETRY_DELAYS_MS } from '../public/js/data.js';
import { DATA, makeMatch } from './match/harness.js';
import { KIT_FILES } from '../server/sim/content/kits/index.js';
import { designText } from './helpers/designDocs.js';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const doc = (p) => readFileSync(join(ROOT, p), 'utf8');
@@ -59,7 +61,8 @@ const matchText = () => [doc('server/match/Match.js'), ...readdirSync(join(ROOT,
const playerText = () => [doc('server/match/PlayerState.js'), ...readdirSync(join(ROOT, 'server/match/player')).sort().map((f) => doc(`server/match/player/${f}`))].join('\n');
// the sources of a former kits/tierN.js: its helpers (kits/shared/tierN.js) and its kit files (kits/ops/, kits/index.js)
const tierSources = (t) => [`server/sim/content/kits/shared/tier${t}.js`, ...KIT_FILES[t - 1].map((f) => `server/sim/content/kits/ops/${f}`)];
const DESIGN = doc('docs/DESIGN.md');
// the design document: the index docs/DESIGN.md and its sections in docs/design/ + docs/history/, in § order
const DESIGN = designText(ROOT);
const META = doc('docs/META.md');
const DATA_MD = doc('docs/DATA.md');
const SIM = doc('docs/SIM.md');
+58 -1
View File
@@ -2,11 +2,16 @@
// (inline code, the diagrams and commands in fenced blocks) and every relative Markdown link. A path with a
// placeholder (`<codename>`, `{a,b}`) or a glob (`*`) is checked up to its last fixed directory; a path ending in `/`
// must be a directory. Outputs that a fresh checkout does not have (downloaded art, vendored libraries) are exempt.
// The design document (0.2.0 split): docs/DESIGN.md is the index, every `## N.` section sits exactly once in
// docs/design/ (the current rules) or docs/history/ (the per-release revisions), the index names each file with its
// sections, and every "DESIGN §N" / "DESIGN §N.M" cited anywhere in the repository is a heading of it.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, readFileSync, statSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { dirname, join, normalize } from 'node:path';
import { fileURLToPath } from 'node:url';
import { designText, DESIGN_DIRS } from './helpers/designDocs.js';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
/** Each document with the least number of paths it names (a guard against an extraction that finds nothing). */
@@ -84,3 +89,55 @@ test('the path extraction: placeholders, globs, directories and the exemptions',
assert.equal(missing('server/no-such-file.js'), 'server/no-such-file.js: not found');
assert.equal(missing('server/index.js/'), 'server/index.js/: not a directory');
});
/** The `## N.` section headings of a Markdown text. */
const sectionsOf = (md) => [...md.matchAll(/^## (\d+)\./gm)].map((m) => Number(m[1]));
test('DESIGN: every section is a `## N.` heading exactly once in docs/design/ or docs/history/, and the index names its file', () => {
const index = readFileSync(join(ROOT, 'docs/DESIGN.md'), 'utf8');
const where = new Map(); // § → the file that holds it
for (const dir of DESIGN_DIRS) {
for (const f of readdirSync(join(ROOT, dir)).filter((x) => x.endsWith('.md')).sort()) {
const file = `${dir}/${f}`;
const md = readFileSync(join(ROOT, file), 'utf8');
assert.match(md, /^# DESIGN §\d+(?:, §\d+)* — .+\n\nPart of \[DESIGN\.md\]\(\.\.\/DESIGN\.md\)/, `${file}: its title and the pointer to the index`);
for (const n of sectionsOf(md)) {
assert.ok(!where.has(n), `§${n} is in ${where.get(n)} and in ${file}`);
where.set(n, file);
}
}
}
assert.deepEqual(sectionsOf(index), [], 'the index holds no section of its own');
const max = Math.max(...where.keys());
assert.ok(max >= 25, `sections up to §${max}`);
for (let n = 0; n <= max; n++) assert.ok(where.has(n), `§${n} has a file`);
// the index's table: one row per file, naming exactly the sections that file holds, with a link that resolves
const listed = new Map();
for (const [, secs, text, href] of index.matchAll(/^\| ((?:§\d+(?:, )?)+) \| \[([^\]]+)\]\(([^)]+)\) \|/gm)) {
const file = `docs/${href}`;
assert.equal(text, href, `${file}: the link text is its path`);
assert.ok(existsSync(join(ROOT, file)), `${file} exists`);
const ns = secs.split(', ').map((x) => Number(x.slice(1)));
const held = [...where].filter(([, f]) => f === file).map(([n]) => n).sort((a, b) => a - b);
assert.deepEqual(ns, held, `${file}: the index row names the sections it holds`);
for (const n of ns) listed.set(n, file);
}
assert.equal(listed.size, where.size, 'the index names every section');
});
test('DESIGN: every "DESIGN §N" cited in the repository (code comments, tests, docs, data) is a heading of the design document', () => {
const heads = new Set([...designText(ROOT).matchAll(/^#{2,4} (\d+(?:\.\d+)*)[. ]/gm)].map((m) => m[1]));
const files = execFileSync('git', ['ls-files', '--cached', '--others', '--exclude-standard'], { cwd: ROOT, encoding: 'utf8' })
.split('\n').filter((f) => /\.(?:m?js|cjs|md|json|html|css|ya?ml|sh|bat|ps1|py|txt)$/.test(f) && !f.startsWith('test/golden/'));
let cited = 0;
const bad = [];
for (const f of files) {
if (!existsSync(join(ROOT, f))) continue; // deleted in the working tree, not yet in the index
for (const m of readFileSync(join(ROOT, f), 'utf8').matchAll(/DESIGN(?:\.md)?[ ,]*§ ?(\d+(?:\.\d+)*)/g)) {
cited++;
if (!heads.has(m[1])) bad.push(`${f}: §${m[1]}`);
}
}
assert.ok(cited >= 700, `only ${cited} citations found — is the scan broken?`);
assert.deepEqual(bad, [], 'citations of a section or subsection with no heading');
});
+59
View File
@@ -0,0 +1,59 @@
// test/helpers/designDocs.js — the design document as one text, for the tests that pin its sentences.
//
// import { designText } from '../helpers/designDocs.js';
// const DESIGN = designText(ROOT); // the index's preamble, then every `## N.` block in § order
//
// docs/DESIGN.md is the index since 0.2.0: the current rules live in docs/design/*.md, the per-release revisions in
// docs/history/*.md, and every `## N.` block (its heading, text and the `---` after it) sits in one of those files
// verbatim. designText() puts the blocks back in section order (not file order — the tests slice it as `## n.` →
// `## n+1.`), so from `## 0.` on it is the pre-split DESIGN.md byte for byte. A section found twice throws.
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { join } from 'node:path';
const HEAD_RE = /^## (\d+)\./;
/** The folders that hold the `## N.` blocks, after the index. */
export const DESIGN_DIRS = ['docs/design', 'docs/history'];
/** Split a Markdown text at its `## N.` headings: { preamble, blocks: Map<N, text> } (each block ends before the next heading). */
export function splitBlocks(text) {
const lines = text.split('\n');
const blocks = new Map();
let cur = null;
let buf = [];
const preamble = [];
const flush = () => {
if (cur == null) preamble.push(...buf);
else {
if (blocks.has(cur)) throw new Error(`§${cur} appears twice`);
blocks.set(cur, buf.join('\n'));
}
buf = [];
};
for (const line of lines) {
const m = line.match(HEAD_RE);
if (m) { flush(); cur = Number(m[1]); }
buf.push(line);
}
flush();
return { preamble: preamble.join('\n'), blocks };
}
/** The design text in § order: the index's preamble, then every part file's blocks sorted by section number. */
export function designText(root) {
const index = readFileSync(join(root, 'docs/DESIGN.md'), 'utf8');
const all = new Map();
const { preamble, blocks: own } = splitBlocks(index);
for (const [n, t] of own) all.set(n, t);
for (const dir of DESIGN_DIRS) {
if (!existsSync(join(root, dir))) continue;
for (const f of readdirSync(join(root, dir)).filter((x) => x.endsWith('.md')).sort()) {
for (const [n, t] of splitBlocks(readFileSync(join(root, dir, f), 'utf8')).blocks) {
if (all.has(n)) throw new Error(`§${n} appears twice (${dir}/${f})`);
all.set(n, t);
}
}
}
return [preamble, ...[...all].sort((a, b) => a[0] - b[0]).map(([, t]) => t)].join('\n');
}
+3 -1
View File
@@ -9,6 +9,7 @@
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { PHASE } from '../../shared/constants.js';
import { GameData } from '../../server/match/gamedata.js';
import { computeBonds, bondList, bondSnapshot, bondsWithGains, HARMONY_BOND } from '../../server/match/bondsMeta.js';
@@ -17,6 +18,7 @@ import { tileKey } from '../../server/match/board.js';
import { validateClientResult } from '../../server/match/fields.js';
import { bandBondIds as bandBondIdsOf } from '../../shared/bandBonds.js';
import { DATA, makeMatch, give, legalTileFor, checkInvariants } from './harness.js';
import { designText } from '../helpers/designDocs.js';
const MLYSS = 'chess_char_6_11_a'; // 缪尔赛思 (调和)
const members = (bond, n) => Object.values(DATA.chess).filter((c) => c.visible && !c.isGolden && c.bonds.includes(bond) && !c.bonds.includes(HARMONY_BOND)).map((c) => c.chessId).sort().slice(0, n);
@@ -256,7 +258,7 @@ describe('§21.26 found on the way — the client-result layer bound reads the l
test('§21.26 docs: DESIGN (the subsection and the normative lines), META, PLAYING and the CHANGELOG say what the code does', () => {
const doc = (p) => readFileSync(new URL(`../../${p}`, import.meta.url), 'utf8');
const DESIGN = doc('docs/DESIGN.md');
const DESIGN = designText(fileURLToPath(new URL('../../', import.meta.url))); // the index + docs/design/ + docs/history/
const at = DESIGN.indexOf('### 21.26 Gaps found while triaging GitHub issues #1 / #8 (v0.1.1)');
assert.ok(at > 0, 'the subsection');
const s = DESIGN.slice(at);
+8 -4
View File
@@ -15,6 +15,9 @@ import {
selectTracked,
} from '../tools/package.mjs';
// a fake home path, built at run time so the repository never contains one (the local push guard scans every patch)
const FAKE_HOME = ['', 'Users', 'someone'].join('/');
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const TOOL = path.join(ROOT, 'tools', 'package.mjs');
// hermetic scans: not this machine's account name, but a planted one
@@ -67,7 +70,8 @@ test('the real tracked tree: runtime in, the rest out; every shipped import, npm
for (const f of ['tools/golden.mjs', 'tools/package.mjs', 'scripts/make-windows-bundle.mjs', 'docs/DESIGN.md', 'eslint.config.js', 'Dockerfile']) {
assert.ok(!got.has(f), f);
}
for (const f of got) assert.ok(!/^(?:test|handoff|\.github|types|public\/dev|docs\/img)\//.test(f), f);
// the design document (the index docs/DESIGN.md and its parts in docs/design/, docs/history/) stays out too
for (const f of got) assert.ok(!/^(?:test|handoff|\.github|types|public\/dev|docs\/img|docs\/design|docs\/history)\//.test(f), f);
const code = [...got].filter((f) => /\.(?:m?js|py)$/.test(f));
for (const f of code) {
const src = fs.readFileSync(path.join(ROOT, f), 'utf8');
@@ -195,7 +199,7 @@ test('scans and guards: home paths and the account name in shipped files (art to
try {
assert.deepEqual(check(), []);
assert.deepEqual(check({ lite: true }), []);
put('public/js/util.js', 'export const u = "/Users/someone/game";\n');
put('public/js/util.js', `export const u = "${FAKE_HOME}/game";\n`);
assert.deepEqual(check(), ['personal info: public/js/util.js (home-directory path)']);
put('public/js/util.js', 'export const u = 1;\n');
put('data/chess.json', '{"p": "C:\\\\Users\\\\Someone\\\\x"}\n');
@@ -205,7 +209,7 @@ test('scans and guards: home paths and the account name in shipped files (art to
assert.deepEqual(check(), ['personal info: public/assets/char/a.png (account name)'], 'binary art, any case');
assert.deepEqual(check({ lite: true }), [], 'the lite zip has no art');
put('public/assets/char/a.png', PNG);
put('test/a.test.js', '// /Users/someone plantedname\n');
put('test/a.test.js', `// ${FAKE_HOME} plantedname\n`);
assert.deepEqual(check(), [], 'files that never ship are not scanned');
rm('public/assets/ui/b c.png');
assert.match(check().join('\n'), /^missing art: public\/assets\/ui\/b c\.png/);
@@ -257,7 +261,7 @@ test('the personal scan reads bytes, never reports the matched text, and skips f
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-package-scan-'));
try {
fs.writeFileSync(path.join(dir, 'a.bin'), Buffer.from([0, 1, 2, ...Buffer.from('/home/someone'), 3]));
fs.writeFileSync(path.join(dir, 'b.txt'), 'see https://github.com/users/x and /usr/lib');
fs.writeFileSync(path.join(dir, 'b.txt'), `see https://github.com/${'users'}/x and /usr/lib`);
fs.writeFileSync(path.join(dir, 'c.txt'), 'Built by PLANTEDNAME');
assert.deepEqual(scanFiles(dir, ['a.bin', 'b.txt', 'c.txt', 'missing.txt'], { names: ['plantedname'] }),
['a.bin (home-directory path)', 'c.txt (account name)']);