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:
commit
8ce6d280bd
30 files changed
+3556
-3096
No files matched your search
@@ -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
@@ -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');
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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)']);
|
||||
|
||||
Reference in new issue
Block a user