Files
LWLT-AIBOT/tools/repository-hygiene.test.mjs
2026-09-17 11:03:19 +08:00

333 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { existsSync } from 'node:fs';
import { readdir, readFile } from 'node:fs/promises';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const DIST = path.join(ROOT, 'dist');
const ROOT_ALLOWLIST = new Set([
'.build',
'.dockerignore',
'.env',
'.env.example',
'.env.production.example',
'.git',
'.gitignore',
'.project-docs',
'AGENTS.md',
'Dockerfile',
'LianSyn-platform',
'README.md',
'agent设计规范',
'archive',
'chrome-extension',
'control-plane',
'dist',
'docker-compose.yml',
'infra',
'mappings',
'node_modules',
'package-lock.json',
'package.json',
'pnpm-lock.yaml',
'quarantine',
'reports',
'samples',
'schemas',
'tools',
'tsconfig.json',
]);
const SKIP_WALK = new Set(['.git', '.build', 'node_modules']);
const RESIDUE_PATTERN = /(^\.DS_Store$|\.log$|\.tmp$|\.swp$|\.pid$|~$)/i;
const TEXT_ARTIFACT_EXTENSIONS = new Set(['.css', '.html', '.js', '.json', '.md', '.mjs', '.py', '.ts', '.txt', '.yaml', '.yml']);
function hashBytes(value) {
return createHash('sha256').update(value).digest('hex');
}
async function sha256(filePath) {
return hashBytes(await readFile(filePath));
}
function artifactContentHash(entry, value) {
if (!TEXT_ARTIFACT_EXTENSIONS.has(path.extname(entry).toLowerCase())) return hashBytes(value);
return hashBytes(Buffer.from(value.toString('utf8').replace(/\r\n/gu, '\n'), 'utf8'));
}
async function normalizedTextSha256(filePath) {
return artifactContentHash(filePath, await readFile(filePath));
}
async function walkFiles(directory, options = {}) {
const { skip = SKIP_WALK } = options;
const output = [];
for (const entry of await readdir(directory, { withFileTypes: true })) {
if (entry.isDirectory() && skip.has(entry.name)) continue;
const absolute = path.join(directory, entry.name);
if (entry.isDirectory()) output.push(...await walkFiles(absolute, options));
else if (entry.isFile() || entry.isSymbolicLink()) output.push(absolute);
}
return output;
}
async function relativeSourceFiles(sourceDirectory) {
const files = await walkFiles(sourceDirectory, { skip: new Set() });
return files
.filter((filePath) => path.basename(filePath) !== '.DS_Store')
.map((filePath) => path.relative(sourceDirectory, filePath).split(path.sep).join('/'))
.sort();
}
function archiveFileEntries(archivePath) {
return execFileSync('unzip', ['-Z1', archivePath], { encoding: 'utf8' })
.split(/\r?\n/)
.map((value) => value.trim())
.filter((value) => value && !value.endsWith('/'))
.sort();
}
function archiveEntry(archivePath, entry) {
return execFileSync('unzip', ['-p', archivePath, entry]);
}
async function assertArchiveMatchesSource({ artifactPath, sourcePath, ignore = new Set() }) {
const sourceEntries = (await relativeSourceFiles(sourcePath)).filter((entry) => !ignore.has(entry));
const artifactEntries = archiveFileEntries(artifactPath).filter((entry) => !ignore.has(entry));
assert.deepEqual(artifactEntries, sourceEntries, `${path.basename(artifactPath)} 文件集合与源码不一致`);
for (const entry of sourceEntries) {
const sourceHash = artifactContentHash(entry, await readFile(path.join(sourcePath, ...entry.split('/'))));
const archiveHash = artifactContentHash(entry, archiveEntry(artifactPath, entry));
assert.equal(archiveHash, sourceHash, `${path.basename(artifactPath)}:${entry} 与源码内容不一致`);
}
}
test('root contains only governed long-lived entries', async () => {
const names = (await readdir(ROOT)).sort();
const unexpected = names.filter((name) => !ROOT_ALLOWLIST.has(name));
assert.deepEqual(unexpected, []);
for (const required of ['README.md', 'AGENTS.md', '.project-docs']) {
assert.ok(names.includes(required), `缺少根目录治理文件 ${required}`);
}
});
test('project docs are the sole active project memory', async () => {
const projectDocs = path.join(ROOT, '.project-docs');
const required = [
'00-brief/project-positioning.md',
'05-agent-entry/concurrent-task-gate.md',
'05-agent-entry/read-before-planning.md',
'10-decisions/decision-index.md',
'20-architecture/system-overview.md',
'30-worklog/current-state.md',
'30-worklog/task-template.md',
'90-maintenance/doc-update-policy.md',
];
for (const relativePath of required) {
assert.ok(existsSync(path.join(projectDocs, relativePath)), `缺少项目记忆文件 .project-docs/${relativePath}`);
}
for (const legacy of ['task_plan.md', 'findings.md', 'progress.md']) {
assert.ok(!existsSync(path.join(ROOT, legacy)), `旧 Planning with Files 文件不得恢复到根目录:${legacy}`);
}
const positioning = await readFile(path.join(projectDocs, '00-brief/project-positioning.md'), 'utf8');
const currentState = await readFile(path.join(projectDocs, '30-worklog/current-state.md'), 'utf8');
const taskRecords = (await readdir(path.join(projectDocs, '30-worklog/tasks')))
.filter((name) => name.endsWith('.md'));
assert.doesNotMatch(positioning, /\{(?:one-line project positioning|primary project goal|primary user or consumer)\}/u);
assert.match(currentState, /^## Integrated Through$/mu);
assert.match(currentState, /Commit `[a-f0-9]{40}`/u);
assert.ok(taskRecords.some((name) => /^\d{8}-[a-z0-9-]+-[a-f0-9]{4,16}\.md$/u.test(name)), '缺少规范 task ID 的项目任务记录');
});
test('temporary residue and report outputs do not pollute the repository', async () => {
const residue = (await walkFiles(ROOT))
.filter((filePath) => RESIDUE_PATTERN.test(path.basename(filePath)))
.map((filePath) => path.relative(ROOT, filePath));
assert.deepEqual(residue, []);
assert.deepEqual((await readdir(path.join(ROOT, 'reports'))).sort(), ['README.md']);
});
test('dist is an exact versioned release set with valid hashes', async () => {
const manifestPath = path.join(DIST, 'release-manifest.json');
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
assert.equal(manifest.manifest_version, 1);
assert.equal(manifest.artifacts.length, 7);
const expected = new Set(['README.md', 'release-manifest.json']);
for (const artifact of manifest.artifacts) {
assert.match(artifact.sha256, /^[a-f0-9]{64}$/);
const artifactPath = path.resolve(ROOT, artifact.path);
assert.equal(path.dirname(artifactPath), DIST, `${artifact.path} 必须直接位于 dist/`);
assert.ok(existsSync(artifactPath), `发布物不存在:${artifact.path}`);
assert.ok(existsSync(path.resolve(ROOT, artifact.source)), `发布源不存在:${artifact.source}`);
assert.equal(await sha256(artifactPath), artifact.sha256, `${artifact.path} SHA-256 不一致`);
expected.add(path.basename(artifactPath));
}
assert.deepEqual((await readdir(DIST)).sort(), [...expected].sort());
assert.equal(manifest.artifacts.filter(({ kind }) => kind === 'chrome_extension').length, 1);
assert.equal(manifest.artifacts.filter(({ kind }) => kind === 'skill').length, 5);
assert.equal(manifest.artifacts.filter(({ kind }) => kind === 'business_instruction_docx').length, 1);
const docx = manifest.artifacts.find(({ kind }) => kind === 'business_instruction_docx');
assert.equal(await normalizedTextSha256(path.resolve(ROOT, docx.source)), docx.source_sha256);
assert.equal(await normalizedTextSha256(path.resolve(ROOT, docx.builder)), docx.builder_sha256);
});
test('extension and release baselines agree', async () => {
const release = JSON.parse(await readFile(path.join(DIST, 'release-manifest.json'), 'utf8'));
const extension = JSON.parse(await readFile(path.join(ROOT, 'chrome-extension/ltjt-order-assistant/manifest.json'), 'utf8'));
const mapping = JSON.parse(await readFile(path.join(ROOT, 'mappings/lifecycle.mapping.json'), 'utf8'));
const platform = await readFile(path.join(ROOT, 'LianSyn-platform/app.js'), 'utf8');
const prompt = await readFile(path.join(ROOT, 'agent设计规范/agent-prompt.md'), 'utf8');
const platformVersion = platform.match(/REQUIRED_EXTENSION_VERSION\s*=\s*['"]([^'"]+)['"]/u)?.[1];
const promptVersion = prompt.match(/prompt_version:\s*([^`\s]+)/u)?.[1];
assert.equal(release.baselines.chrome_extension, extension.version);
assert.equal(mapping.current_extension_version, extension.version);
assert.equal(platformVersion, extension.version);
assert.equal(promptVersion, release.baselines.agent_prompt);
const extensionArtifact = release.artifacts.find(({ kind }) => kind === 'chrome_extension');
assert.equal(extensionArtifact.version, extension.version);
assert.equal(path.basename(extensionArtifact.path), `ltjt-order-assistant-${extension.version}.zip`);
for (const skill of release.artifacts.filter(({ kind }) => kind === 'skill')) {
assert.equal(skill.version, release.baselines.skills);
assert.equal(path.basename(skill.path), `${skill.name}-${skill.version}.skill`);
}
const docx = release.artifacts.find(({ kind }) => kind === 'business_instruction_docx');
assert.equal(docx.version, release.baselines.business_instruction_docx);
assert.equal(path.basename(docx.path), `老挝联泰AI指令表-${docx.version}.docx`);
});
test('packaged Skills and extension runtime match their project sources', async () => {
const release = JSON.parse(await readFile(path.join(DIST, 'release-manifest.json'), 'utf8'));
for (const artifact of release.artifacts.filter(({ kind }) => kind === 'skill')) {
await assertArchiveMatchesSource({
artifactPath: path.resolve(ROOT, artifact.path),
sourcePath: path.resolve(ROOT, artifact.source),
});
}
const extension = release.artifacts.find(({ kind }) => kind === 'chrome_extension');
await assertArchiveMatchesSource({
artifactPath: path.resolve(ROOT, extension.path),
sourcePath: path.resolve(ROOT, extension.source),
// README is compacted as project governance documentation; runtime bytes remain immutable.
ignore: new Set(['README.md']),
});
});
test('active Markdown links resolve and current docs do not depend on rolling history', async () => {
const allMarkdown = (await walkFiles(ROOT))
.filter((filePath) => filePath.endsWith('.md'))
.filter((filePath) => !filePath.startsWith(`${path.join(ROOT, 'archive')}${path.sep}`));
const broken = [];
const forbidden = [];
const linkPattern = /\[[^\]]*\]\(([^)]+)\)/gu;
for (const filePath of allMarkdown) {
const content = await readFile(filePath, 'utf8');
if (filePath.includes(`${path.sep}agent设计规范${path.sep}businesses${path.sep}`)) {
if (/\.\.\/\.\.\/(findings|progress)\.md/u.test(content)) {
forbidden.push(path.relative(ROOT, filePath));
}
}
if (/\]\([^)]*(HANDOFF|开发维护规范)\.md/u.test(content)) {
forbidden.push(path.relative(ROOT, filePath));
}
for (const match of content.matchAll(linkPattern)) {
let target = match[1].trim();
if (target.startsWith('<') && target.endsWith('>')) target = target.slice(1, -1);
if (/^[a-z][a-z0-9+.-]*:/iu.test(target) || target.startsWith('#')) continue;
target = target.split('#', 1)[0].split('?', 1)[0];
if (!target) continue;
try {
target = decodeURIComponent(target);
} catch {
broken.push(`${path.relative(ROOT, filePath)} -> ${target}(URL 编码无效)`);
continue;
}
const resolved = path.resolve(path.dirname(filePath), target);
if (!existsSync(resolved)) broken.push(`${path.relative(ROOT, filePath)} -> ${target}`);
}
}
assert.deepEqual([...new Set(forbidden)].sort(), []);
assert.deepEqual(broken.sort(), []);
});
test('archive README indexes remain navigable', async () => {
const archiveRoot = path.join(ROOT, 'archive');
const readmes = (await walkFiles(archiveRoot, { skip: new Set() }))
.filter((filePath) => path.basename(filePath) === 'README.md');
const broken = [];
const linkPattern = /\[[^\]]*\]\(([^)]+)\)/gu;
for (const filePath of readmes) {
const content = await readFile(filePath, 'utf8');
for (const match of content.matchAll(linkPattern)) {
let target = match[1].trim();
if (target.startsWith('<') && target.endsWith('>')) target = target.slice(1, -1);
if (/^[a-z][a-z0-9+.-]*:/iu.test(target) || target.startsWith('#')) continue;
target = target.split('#', 1)[0].split('?', 1)[0];
if (!target) continue;
try {
target = decodeURIComponent(target);
} catch {
broken.push(`${path.relative(ROOT, filePath)} -> ${target}(URL 编码无效)`);
continue;
}
if (!existsSync(path.resolve(path.dirname(filePath), target))) {
broken.push(`${path.relative(ROOT, filePath)} -> ${target}`);
}
}
}
assert.deepEqual(broken.sort(), []);
});
test('single-source and generated-output boundaries remain explicit', async () => {
const template = await readFile(path.join(ROOT, 'agent设计规范/templates/business-input-templates.md'), 'utf8');
const tsconfig = JSON.parse(await readFile(path.join(ROOT, 'tsconfig.json'), 'utf8'));
const packageJson = JSON.parse(await readFile(path.join(ROOT, 'package.json'), 'utf8'));
const dockerfile = await readFile(path.join(ROOT, 'Dockerfile'), 'utf8');
const compose = await readFile(path.join(ROOT, 'docker-compose.yml'), 'utf8');
const diagnoseServer = await readFile(path.join(ROOT, 'infra/diagnose-server.sh'), 'utf8');
const gitignore = await readFile(path.join(ROOT, '.gitignore'), 'utf8');
assert.match(template, /^交付版本:0\.5\.136\s*$/mu);
assert.match(template, /^适用范围:当前 23 条业务路由,按 19 项运营场景分组;执行能力以生命周期发布门槛为准。\s*$/mu);
assert.match(template, /^### 5\. 散拼团多个新增子单\s*$/mu);
assert.doesNotMatch(template, /^事实基线:/mu);
assert.equal(tsconfig.compilerOptions.outDir, '.build');
assert.match(packageJson.scripts.start, /\.build\/control-plane/u);
assert.match(dockerfile, /\.build\/control-plane/u);
assert.match(compose, /\.build\/control-plane/u);
assert.match(compose, /x-default-logging:\s*&default-logging/u);
assert.match(compose, /max-size:\s*"\$\{LOG_MAX_SIZE:-20m\}"/u);
assert.match(compose, /max-file:\s*"\$\{LOG_MAX_FILES:-10\}"/u);
assert.equal((compose.match(/logging:\s*\*default-logging/gu) || []).length, 3);
assert.match(diagnoseServer, /health\/ready/u);
assert.match(diagnoseServer, /docker compose --env-file \.env\.production/u);
assert.doesNotMatch(diagnoseServer, /cat\s+\.env|printenv|env\s*$/mu);
assert.match(gitignore, /^\.build\/$/mu);
assert.match(gitignore, /^\.env$/mu);
assert.ok(!existsSync(path.join(DIST, 'control-plane')));
assert.ok(!existsSync(path.join(DIST, 'ltjt-order-assistant.zip')));
});
test('runtime image includes both LibreOffice document converters', async () => {
const dockerfile = await readFile(path.join(ROOT, 'Dockerfile'), 'utf8');
assert.match(dockerfile, /apt-get install[^\n]*libreoffice-calc/u);
assert.match(dockerfile, /apt-get install[^\n]*libreoffice-writer/u);
});