diff --git a/web/README.md b/web/README.md index 2731ac7..9d8b4f0 100644 --- a/web/README.md +++ b/web/README.md @@ -36,4 +36,6 @@ PYTHON=../.venv/bin/python npm --prefix web run build 检查使用只读 GitHub 权限和临时构建目录;不上传网页、文档 ZIP 或记录正文,不配置飞书 Token,也不自动部署网站。新增资料仍需在 PR 中说明来源与公开范围,由资料负责人复核。需登录的飞书链接不做外链存活检查。 +YAML 或日期元信息错误只报告文件位置和修正要求,不在日志中回显原文。测试和构建均使用 `PYTHON` 指定的解释器,未指定时使用 `python3`。 + main 合并规则使用 `Community checks` 作为必需检查,要求分支与 main 同步并解决评审对话。维护者可在仓库 Settings → Branches 查看规则;首次贡献者的 fork 工作流仍遵循 GitHub 的运行审批设置。 diff --git a/web/package.json b/web/package.json index b9e825b..ab2b71f 100644 --- a/web/package.json +++ b/web/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "build": "node build.mjs", - "test": "python3 tests/verify.py", + "test": "node tests/run.mjs", "start": "python3 -m http.server 5274 --bind 127.0.0.1 --directory site" }, "engines": {"node": ">=20"}, diff --git a/web/read-records.py b/web/read-records.py index 74b0483..83b647a 100644 --- a/web/read-records.py +++ b/web/read-records.py @@ -46,8 +46,14 @@ if meta[key] not in allowed: raise ValueError(f'{key}: expected one of {sorted(allowed)}') for key in ('date', 'updated'): - meta[key] = str(meta[key]) - date.fromisoformat(meta[key]) + value = str(meta[key]) + if not re.fullmatch(r'\d{4}-\d{2}-\d{2}', value): + raise ValueError(f'{key} must be a YYYY-MM-DD date') + try: + date.fromisoformat(value) + except ValueError: + raise ValueError(f'{key} must be a valid calendar date') from None + meta[key] = value if not isinstance(meta['example'], bool): raise ValueError('example must be true or false') if rel.parts[:2] == ('examples', 'records') and not meta['example']: @@ -56,7 +62,13 @@ if key in meta and (not isinstance(meta[key], str) or not re.match(r'^https?://[^\s]+$', meta[key])): raise ValueError(f'{key} must be an http(s) URL') records.append(dict(meta, source_path='community/' + rel.as_posix(), body=text[match.end():].strip())) - except (ValueError, yaml.YAMLError) as exc: + except yaml.YAMLError as exc: + mark = getattr(exc, 'problem_mark', None) + location = f' at line {mark.line + 2}, column {mark.column + 1}' if mark else '' + errors.append(f'{rel}: invalid YAML metadata{location}; source text omitted') + except UnicodeError: + errors.append(f'{rel}: record must use UTF-8 encoding; source text omitted') + except ValueError as exc: errors.append(f'{rel}: {exc}') seen = set() for r in records: diff --git a/web/tests/run.mjs b/web/tests/run.mjs new file mode 100644 index 0000000..9372ca8 --- /dev/null +++ b/web/tests/run.mjs @@ -0,0 +1,8 @@ +import {spawnSync} from 'node:child_process'; +import {fileURLToPath} from 'node:url'; + +const result = spawnSync(process.env.PYTHON || 'python3', + [fileURLToPath(new URL('./verify.py', import.meta.url)), ...process.argv.slice(2)], + {stdio: 'inherit'}); +if (result.error) console.error('Unable to start the configured Python interpreter. Check PYTHON.'); +process.exit(result.status ?? 1); diff --git a/web/tests/verify.py b/web/tests/verify.py index 56af553..6d45a78 100644 --- a/web/tests/verify.py +++ b/web/tests/verify.py @@ -13,6 +13,45 @@ def handle_starttag(self, tag, attrs): self.urls += [v for k, v in attrs if k in ('href', 'src') and v] class RecordWorkflow(unittest.TestCase): + def test_invalid_metadata_does_not_echo_source_text(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + records = root / 'records' + records.mkdir() + file = records / 'invalid.md' + secret = 'SYNTHETIC-PRIVATE-METADATA-DO-NOT-LOG' + template = (BASE.parent / 'templates/meeting.md').read_text() + _, front, _ = template.split('---', 2) + meta = dict(yaml.safe_load(front), id='VERIFY-ERROR', title=secret, + summary=secret, reviewer='Test reviewer', source=secret, + source_revision='test-v1') + cases = ['---\nid: TEST\nsource: [' + secret + '\n---\n', + '---\n' + yaml.safe_dump(dict(meta, date=secret)) + '---\n', + '---\n' + yaml.safe_dump(dict(meta, updated='2026-02-30')) + '---\n'] + for content in cases: + with self.subTest(content_type=cases.index(content)): + file.write_text(content + secret) + for command in ([sys.executable, str(BASE / 'check-public-records.py'), str(root)], + ['node', str(BASE / 'build.mjs')]): + result = subprocess.run(command, capture_output=True, text=True, + env=dict(os.environ, CONTENT_ROOT=str(root), + SITE_OUTPUT=str(root / 'site'), PYTHON=sys.executable)) + self.assertNotEqual(result.returncode, 0) + self.assertIn('invalid.md', result.stderr) + self.assertNotIn(secret, result.stdout + result.stderr) + + def test_npm_test_uses_the_selected_python(self): + with tempfile.TemporaryDirectory() as temp: + interpreter = Path(temp) / 'python probe' + interpreter.write_text('#!' + sys.executable + '\n' + 'import sys\nprint("SELECTED-PYTHON", sys.argv[1])\nsys.exit(7)\n') + interpreter.chmod(0o755) + result = subprocess.run(['npm', 'test'], cwd=BASE, capture_output=True, text=True, + env=dict(os.environ, PYTHON=str(interpreter))) + self.assertEqual(result.returncode, 7) + self.assertIn('SELECTED-PYTHON', result.stdout) + self.assertIn('verify.py', result.stdout) + def test_public_visibility_keeps_internal_originals_out_of_real_records(self): with tempfile.TemporaryDirectory() as temp: root = Path(temp) @@ -52,7 +91,7 @@ def test_ingest_languages_metadata_and_links(self): if (BASE/'drafts').is_dir(): shutil.copytree(BASE/'drafts', drafts) else: - shutil.copytree(BASE.parent, drafts/'community', ignore=shutil.ignore_patterns('.git','web')) + shutil.copytree(BASE.parent, drafts/'community', ignore=shutil.ignore_patterns('.git','web','.venv','__pycache__')) shutil.copytree(BASE/'organization-profile', drafts/'.github') records = drafts/'community/records/meetings' records.mkdir(parents=True, exist_ok=True)