333 lines
15 KiB
JavaScript
333 lines
15 KiB
JavaScript
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\.127\s*$/mu);
|
||
assert.match(template, /^适用范围:当前 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);
|
||
});
|