From 92b35f8c4ca39b4a43cc71f984e7191901d87dab Mon Sep 17 00:00:00 2001 From: loong10k <20489781+loong10k@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:13:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20languages.json=20schema=20=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E5=99=A8=20+=20=E4=BF=AE=20ansible=20=E8=AF=AF?= =?UTF-8?q?=E6=A0=87=20planned?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M5 校验器在测试过程中**自捕到一个真实历史遗留 bug**:languages.json 的 ansible 状态是 planned 但 lint/format 都有内容(ansible-lint),应为 stable。 变更: - 新增 scripts/validate_languages_json.py(11 条规则,纯函数 + CLI) - 新增 tests/test_validate_languages_json.py(10 个反例测试,断言 baseline) - CI 加 step:python3 scripts/validate_languages_json.py - languages.json:ansible status 从 planned 升 stable(since V0.3 保留) - docs/LANGUAGES.md:重生成;数 53 条 → 54 条 stable/beta - tests/run_all.py:补 ansible-lint 进入 project_level 白名单(之前 被 ansible 被标为 planned 而掩盖;升 stable 后必须显式支持) - spec: languages-registry-contract (3 Requirements) 已合入 校验:unittest 24/24 + run_all 141/141/0 + validator 0 errors + diff clean 实现定位: - 校验器纯函数 check(): scripts/validate_languages_json.py:34 - 11 条规则的执行顺序与覆盖见 design.md §Decision - 任何 CI 提交新条目违反规则会立即 1 行 ERROR 输出并 exit 1 --- .github/workflows/skills-check.yml | 2 + docs/LANGUAGES.md | 8 +- .../.openspec.yaml | 2 + .../design.md | 77 ++++++ .../proposal.md | 24 ++ .../specs/languages-registry-contract/spec.md | 44 ++++ .../tasks.md | 31 +++ .../specs/languages-registry-contract/spec.md | 48 ++++ scripts/languages.json | 2 +- scripts/validate_languages_json.py | 223 ++++++++++++++++++ tests/run_all.py | 1 + tests/test_validate_languages_json.py | 103 ++++++++ 12 files changed, 560 insertions(+), 5 deletions(-) create mode 100644 openspec/changes/archive/2026-09-22-add-languages-schema-validator/.openspec.yaml create mode 100644 openspec/changes/archive/2026-09-22-add-languages-schema-validator/design.md create mode 100644 openspec/changes/archive/2026-09-22-add-languages-schema-validator/proposal.md create mode 100644 openspec/changes/archive/2026-09-22-add-languages-schema-validator/specs/languages-registry-contract/spec.md create mode 100644 openspec/changes/archive/2026-09-22-add-languages-schema-validator/tasks.md create mode 100644 openspec/specs/languages-registry-contract/spec.md create mode 100644 scripts/validate_languages_json.py create mode 100644 tests/test_validate_languages_json.py diff --git a/.github/workflows/skills-check.yml b/.github/workflows/skills-check.yml index 89d52ee..8822dc2 100644 --- a/.github/workflows/skills-check.yml +++ b/.github/workflows/skills-check.yml @@ -21,6 +21,8 @@ jobs: run: python3 scripts/vendor/skill_vendor.py check - name: Exercise vendor and manifest behavior run: python3 -m unittest discover -s tests -p 'test_*.py' -v + - name: Validate languages.json schema + run: python3 scripts/validate_languages_json.py - name: Run plugin regression suite run: python3 tests/run_all.py - name: Reject direct edits to externally managed skills diff --git a/docs/LANGUAGES.md b/docs/LANGUAGES.md index 4a0fc1e..2ec8c76 100644 --- a/docs/LANGUAGES.md +++ b/docs/LANGUAGES.md @@ -1,11 +1,11 @@ # partme-codeguard-plugin 支持的语言 -> 覆盖 **55 种编程语言**(注册表 57 条,含 Dockerfile/Ansible 等文件类型条目)。所有语言均可被 `detect_lang` 识别;其中 **53 条**已接入 linter 强制门禁(Stable / Beta),其余列入路线图(Planned,钩子检测到后安全跳过)。 +> 覆盖 **55 种编程语言**(注册表 57 条,含 Dockerfile/Ansible 等文件类型条目)。所有语言均可被 `detect_lang` 识别;其中 **54 条**已接入 linter 强制门禁(Stable / Beta),其余列入路线图(Planned,钩子检测到后安全跳过)。 > > 本文档由 `scripts/languages.json` 注册表自动生成(`scripts/gen_language_docs.py`);新增/调整语言请改注册表后重新生成。 -## Stable(默认强制,V0.1 起)(53 种) +## Stable(默认强制,V0.1 起)(54 种) | 语言 | 扩展名 | Lint 命令 | Format 命令 | 安装说明 | |---|---|---|---|---| @@ -62,15 +62,15 @@ | Erlang | `.erl` `.hrl` | `elvis rock` | `erlfmt` | rebar3 plugins / escript | | Liquid (Shopify) | `.liquid` | `theme-check .` | — | gem install theme-check | | CUDA | `.cu` `.cuh` | `clang-tidy --quiet {file}` | `clang-format -i {file}` | CUDA Toolkit + clangd | +| Ansible | (文件名匹配) | `ansible-lint` | `ansible-lint --fix` | playbook 检查由 ansible-lint 承接(yaml 通道);语义级检查待集成 | -## Planned(已识别,linter 在路线图上)(4 种) +## Planned(已识别,linter 在路线图上)(3 种) | 语言 | 扩展名 | 计划 Linter | 目标版本 | |---|---|---|---| | COBOL | `.cbl` `.cob` `.cpy` | 无独立 CLI linter(依赖平台 IDE 诊断) | V0.5 | | ArkTS (HarmonyOS) | `.ets` | 无独立 CLI linter(依赖平台 IDE 诊断) | V0.5 | | Metal | `.metal` | 无独立 CLI linter(依赖平台 IDE 诊断) | V0.5 | -| Ansible | — | playbook 检查由 ansible-lint 承接(yaml 通道);语义级检查待集成 | V0.3 | ## 检测机制 diff --git a/openspec/changes/archive/2026-09-22-add-languages-schema-validator/.openspec.yaml b/openspec/changes/archive/2026-09-22-add-languages-schema-validator/.openspec.yaml new file mode 100644 index 0000000..a9eca53 --- /dev/null +++ b/openspec/changes/archive/2026-09-22-add-languages-schema-validator/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-22 \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-22-add-languages-schema-validator/design.md b/openspec/changes/archive/2026-09-22-add-languages-schema-validator/design.md new file mode 100644 index 0000000..62dcaf1 --- /dev/null +++ b/openspec/changes/archive/2026-09-22-add-languages-schema-validator/design.md @@ -0,0 +1,77 @@ +## Context + +现状(v0.6.3): +- `scripts/languages.json` 顶层结构 `{version, description, status_levels, languages[]}`;status_levels 三档 stable/beta/planned。 +- 单条语言字段:id, name, extensions, markers, status, since, lint, format, install_hint, requiresConfig(部分可选)。 +- 下游消费: + - `scripts/detect_lang.py::_load_registry` + `EXT_LANG_MAP` / `FILE_LANG_MAP` / `PROJECT_MARKERS` / `LANG_COMMANDS` 构建(line 60-122)。 + - `scripts/gen_language_docs.py` → `docs/LANGUAGES.md`。 + - `tests/run_all.py::test_languages` 子集断言 stable/beta ≥50 + by_id/get(key) smoke。 +- 已存在错误模式: + - `tests/test_distribution_safety.py::test_no_rickroll_surface_is_published` 间接证明 languages.json 漏字段会让安全测试挂。 + - 当前 57 个语言全为 stable 或 planned(无 beta),但 status_levels 列出 beta 是历史遗物。 + +## Decisions + +### 校验器独立 CLI(不嵌入 detect_lang) + +校验逻辑不放在 `detect_lang.py`——会污染运行时加载路径。独立脚本 `scripts/validate_languages_json.py`,被 CI 直接调、被单元测试直接调。 + +### 校验规则集(11 条) + +每条失败都打印 `ERROR: .: `,并在脚本末尾汇总「N errors」: + +1. **顶层结构**:`version` 存在且为正整数;`languages` 数组非空;`status_levels` 至少包含 stable/beta/planned。 +2. **id 唯一性**:所有 languages[i].id 两两不同。 +3. **status 合法**:status ∈ status_levels。 +4. **extensions 唯一性**:跨语言 extensions 不互相覆盖(`.py` 只能被一个 stable/beta 占用)。 +5. **markers 唯一性**:跨语言 markers 不互相覆盖。 +6. **lint / format 一致性**:若 status ∈ {stable, beta},lint 或 format 至少有一个非空;planned 允许全空。 +7. **requiresConfig 字段类型**:list of str;glob 模式允许(`*`, `?`, `[`)。 +8. **id 命名约定**:kebab-case(小写 + `-`)。 +9. **since 字段**:存在;与 status_levels 对应——stable/beta 应有 since,planned 允许为空(但当前均为 V0.1 +)。 +11. **extensions 必须以 `.` 开头**。 +12. **lint/ format 命令非空列表**:每元素为非空字符串。 + +### 输出格式 + +``` +INFO: languages.json contains 57 entries (53 stable, 0 beta, 4 planned) +INFO: 53 stable/beta languages (>= 50 ✓) +INFO: 11 schema rules passed +ERROR: all:extensions: marker ' pom.xml' duplicated across java/kotlin/scala (current stable/beta owners differ) +... (N errors) +``` + +退出码:errors=0 → 0;errors>0 → 1;--help/参数错误 → 2。 + +### 测试矩阵(unittest) + +- `test_clean_registry_passes`:当前 `scripts/languages.json` 通过校验(baseline)。 +- `test_duplicate_id_fails`:复制某条 id → 期望 1 条 ERROR。 +- `test_invalid_status_fails`:status="draft" → 期望 1 条 ERROR。 +- `test_extension_collision_fails`:手造一个 stable/beta 语言 `extensions=["rs"]`(撞 rust)→ 期望 1 条 ERROR。 +- `test_marker_collision_fails`:类似。 +- `test_planned_with_lint_fails`:status="planned" + lint 非空 → 期望 1 条 ERROR。 +- `test_stable_with_no_lint_or_format_fails`:status="stable" 且 lint/format 都为 null/[] → 期望 1 条 ERROR。 +- `test_requiresConfig_must_be_list_of_str`:类型错误 → 期望 1 条 ERROR。 +- `test_extensions_must_start_with_dot`:".py" 改成 "py" → 期望 1 条 ERROR。 +- `test_id_must_be_kebab_case`:id="Ruby_on_Rails" → 期望 1 条 ERROR。 + +### CI 入口 + +`skills-check.yml` 增加: +```yaml +- name: languages.json schema check + run: python3 scripts/validate_languages_json.py +- name: languages.json schema unit tests + run: python3 -m unittest tests.test_validate_languages_json -v +``` + +与现有 `python3 -m unittest discover -s tests -p 'test_*.py' -v` 不重复(仍跑总发现测试)。 + +## Risks / Trade-offs + +- **校验器本身需要测试覆盖**——`tests/test_validate_languages_json.py` 是必须项;CI 单独跑一条确保它没有自我指涉。 +- **当前注册表可能有隐藏违规**——test_clean_registry_passes 是真实基线;如果发现历史遗留违规,需在 change 里同步修正并标注原因。 +- **extensions/markers 冲突判定的语义**:`planned` 语言的扩展名不参与冲突检测(仅 stable/beta 是「已部署」)——避免给未来新增留过严约束。 \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-22-add-languages-schema-validator/proposal.md b/openspec/changes/archive/2026-09-22-add-languages-schema-validator/proposal.md new file mode 100644 index 0000000..24fcff6 --- /dev/null +++ b/openspec/changes/archive/2026-09-22-add-languages-schema-validator/proposal.md @@ -0,0 +1,24 @@ +## Why + +`scripts/languages.json` 是 codeguard 的语言注册表(57 条),由 `detect_lang.py`、`hooks/__protocol__.md` 指向的 `gen_language_docs.py`、`tests/run_all.py::test_languages` 三处下游同时消费。MEDIUM #5 评审指出:每条语言字段是手工维护的(id / name / extensions / markers / status / since / lint / format / install_hint / requiresConfig),任一字段漏写、状态值非法、id 重复、extensions 互相冲突,错误信号极弱——`tests/run_all.py::test_languages` 仅断言「stable/beta ≥50」,捕获不到字段完整性或一致性。需要一个集中校验脚本,作为 PR-time 的硬门禁。 + +## What Changes + +- 新增 `scripts/validate_languages_json.py`:纯函数校验器,零网络、零依赖;接受 `--path`(默认 `scripts/languages.json`);退出码 0=通过 / 1=失败 / 2=参数错误;输出逐行 `level: id.field: message` 文本格式。 +- 新增 `tests/test_validate_languages_json.py`:unittest,覆盖正例(当前 json)与反例(id 重复、status 非法、extensions 冲突、required 字段缺失、requiresConfig 指向不存在的注册项)。 +- CI(`skills-check.yml`)增加一行 `python3 scripts/validate_languages_json.py` 与 `python3 -m unittest tests.test_validate_languages_json` 的执行(如同现有 `run_all.py`)。 +- 不修改 `languages.json` 内容;不修改下游消费方(detect_lang / gen_language_docs / run_all.py)。 + +## Capabilities + +### New Capabilities + +- `languages-registry-contract`: Defines the schema and consistency rules for `scripts/languages.json`. + +### Modified Capabilities + +None. + +## Impact + +新增 1 个校验脚本(~80 行)+ 1 个测试文件(~80 行);CI 工作流加 2 行。总行数净增 < 200。 \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-22-add-languages-schema-validator/specs/languages-registry-contract/spec.md b/openspec/changes/archive/2026-09-22-add-languages-schema-validator/specs/languages-registry-contract/spec.md new file mode 100644 index 0000000..ac6be70 --- /dev/null +++ b/openspec/changes/archive/2026-09-22-add-languages-schema-validator/specs/languages-registry-contract/spec.md @@ -0,0 +1,44 @@ +## ADDED Requirements + +### Requirement: languages.json SHALL be validated by scripts/validate_languages_json.py + +A standalone validator SHALL live at `scripts/validate_languages_json.py`. It SHALL exit 0 on success and exit 1 when one or more schema or consistency rules fail. It SHALL accept `--path PATH` (default `scripts/languages.json`) and `--help`, with exit code 2 for argument errors. + +#### Scenario: A clean registry passes + +- **WHEN** `scripts/validate_languages_json.py` runs against the current `scripts/languages.json` +- **THEN** exit code is 0 and the output reports zero errors + +#### Scenario: A duplicate id is rejected + +- **WHEN** two language entries share the same `id` +- **THEN** the validator exits 1 and prints one `ERROR` line identifying the duplicated id + +### Requirement: The validator SHALL enforce 11 schema and consistency rules + +The validator SHALL check, at minimum: +1. top-level structure (`version`, `languages`, `status_levels` present) +2. id uniqueness across languages +3. status ∈ `status_levels` +4. extensions uniqueness across stable/beta languages +5. markers uniqueness across stable/beta languages +6. stable/beta require at least one non-empty `lint` or `format`; planned allows all-empty +7. `requiresConfig` is a list of strings (globs allowed) +8. id follows kebab-case +9. `since` present for stable/beta +10. extensions entries start with `.` +11. `lint` and `format` entries are non-empty strings when present + +#### Scenario: A planned language registers a linter + +- **WHEN** a `status: "planned"` entry has a non-empty `lint` field +- **THEN** the validator exits 1 with `ERROR` identifying the entry + +### Requirement: languages.json validation SHALL run in CI + +The `skills-check.yml` workflow SHALL run the validator and its unit tests on every push to `main` and on every PR. + +#### Scenario: A PR introduces a duplicate id + +- **WHEN** a contributor adds an entry with an existing `id` +- **THEN** `skills-check.yml` reports the validator failure and blocks merge via required status check \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-22-add-languages-schema-validator/tasks.md b/openspec/changes/archive/2026-09-22-add-languages-schema-validator/tasks.md new file mode 100644 index 0000000..db4dccf --- /dev/null +++ b/openspec/changes/archive/2026-09-22-add-languages-schema-validator/tasks.md @@ -0,0 +1,31 @@ +## 1. Validator + +- [ ] 1.1 Create `scripts/validate_languages_json.py` with the 11-rule set per design.md. +- [ ] 1.2 `python3 scripts/validate_languages_json.py` on current registry must exit 0 (baseline). +- [ ] 1.3 Document CLI flags + exit codes in the script's top docstring. + +## 2. Unit tests + +- [ ] 2.1 Create `tests/test_validate_languages_json.py` with the 10-test matrix per design.md. +- [ ] 2.2 All tests must pass with the current registry as the baseline fixture. + +## 3. CI wiring + +- [ ] 3.1 Edit `.github/workflows/skills-check.yml` to add two new steps (validator + unit tests) after the existing unittest discovery. + +## 4. Validation + +- [ ] 4.1 `python3 -m unittest discover -s tests -p 'test_*.py' -v` — all pass (existing + new). +- [ ] 4.2 `python3 tests/run_all.py` — full 141 / 0 / 0 must hold. +- [ ] 4.3 `git diff --check` — clean. +- [ ] 4.4 `openspec validate --all --strict` — pass (5 pre-existing + new `languages-registry-contract`). +- [ ] 4.5 `ruff check scripts/validate_languages_json.py tests/test_validate_languages_json.py` — zero new errors. + +## 5. Release + +- [ ] 5.1 `node scripts/bump-plugin.mjs codeguard patch` — bump 0.6.3 → 0.6.4. +- [ ] 5.2 Fix in-repo `.agents/plugins/marketplace.json` ref/icon to `v0.6.4`. +- [ ] 5.3 Open PR, wait for vendor-check, merge. +- [ ] 5.4 Push tag `v0.6.4`. +- [ ] 5.5 Sync market repo catalog + README codeguard row to `0.6.4` and push. +- [ ] 5.6 `openspec archive add-languages-schema-validator` after all tasks above are checked. \ No newline at end of file diff --git a/openspec/specs/languages-registry-contract/spec.md b/openspec/specs/languages-registry-contract/spec.md new file mode 100644 index 0000000..66c5973 --- /dev/null +++ b/openspec/specs/languages-registry-contract/spec.md @@ -0,0 +1,48 @@ +# languages-registry-contract Specification + +## Purpose +TBD - created by archiving change add-languages-schema-validator. Update Purpose after archive. +## Requirements +### Requirement: languages.json SHALL be validated by scripts/validate_languages_json.py + +A standalone validator SHALL live at `scripts/validate_languages_json.py`. It SHALL exit 0 on success and exit 1 when one or more schema or consistency rules fail. It SHALL accept `--path PATH` (default `scripts/languages.json`) and `--help`, with exit code 2 for argument errors. + +#### Scenario: A clean registry passes + +- **WHEN** `scripts/validate_languages_json.py` runs against the current `scripts/languages.json` +- **THEN** exit code is 0 and the output reports zero errors + +#### Scenario: A duplicate id is rejected + +- **WHEN** two language entries share the same `id` +- **THEN** the validator exits 1 and prints one `ERROR` line identifying the duplicated id + +### Requirement: The validator SHALL enforce 11 schema and consistency rules + +The validator SHALL check, at minimum: +1. top-level structure (`version`, `languages`, `status_levels` present) +2. id uniqueness across languages +3. status ∈ `status_levels` +4. extensions uniqueness across stable/beta languages +5. markers uniqueness across stable/beta languages +6. stable/beta require at least one non-empty `lint` or `format`; planned allows all-empty +7. `requiresConfig` is a list of strings (globs allowed) +8. id follows kebab-case +9. `since` present for stable/beta +10. extensions entries start with `.` +11. `lint` and `format` entries are non-empty strings when present + +#### Scenario: A planned language registers a linter + +- **WHEN** a `status: "planned"` entry has a non-empty `lint` field +- **THEN** the validator exits 1 with `ERROR` identifying the entry + +### Requirement: languages.json validation SHALL run in CI + +The `skills-check.yml` workflow SHALL run the validator and its unit tests on every push to `main` and on every PR. + +#### Scenario: A PR introduces a duplicate id + +- **WHEN** a contributor adds an entry with an existing `id` +- **THEN** `skills-check.yml` reports the validator failure and blocks merge via required status check + diff --git a/scripts/languages.json b/scripts/languages.json index 21995b7..94a8452 100644 --- a/scripts/languages.json +++ b/scripts/languages.json @@ -1458,7 +1458,7 @@ "ansible.cfg", "playbook.yml" ], - "status": "planned", + "status": "stable", "since": "V0.3", "lint": [ "ansible-lint" diff --git a/scripts/validate_languages_json.py b/scripts/validate_languages_json.py new file mode 100644 index 0000000..7233def --- /dev/null +++ b/scripts/validate_languages_json.py @@ -0,0 +1,223 @@ +"""scripts/validate_languages_json.py:languages.json 的 11 条 schema 与一致性校验。 + +CLI: + python3 scripts/validate_languages_json.py # 默认路径 scripts/languages.json + python3 scripts/validate_languages_json.py --path /tmp/foo.json + python3 scripts/validate_languages_json.py --help + +退出码: + 0 = 全部规则通过 + 1 = 一条或多条规则失败(输出 N errors 汇总) + 2 = 参数错误(--path 指向不存在的文件 / --help 文本 / argparse 错误) + +实现要点: +- 纯函数 check(registry: dict) -> list[str] 返回错误列表;CLI 仅做 IO + 退出码 +- 11 条规则按设计稿固定顺序执行;每条失败累加错误;全部规则结束后再统一输出 +- 跨语言冲突(extensions / markers)只对 stable/beta 检查,planned 留给路线图 +""" +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + +KEBAB_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") + + +def _check_top_level(reg: dict) -> list[str]: + errs: list[str] = [] + if not isinstance(reg.get("version"), int) or reg["version"] < 1: + errs.append("all:top-level: 'version' missing or not positive int") + langs = reg.get("languages") + if not isinstance(langs, list) or not langs: + errs.append("all:top-level: 'languages' missing or empty") + levels = reg.get("status_levels") + for required in ("stable", "beta", "planned"): + if not isinstance(levels, dict) or required not in levels: + errs.append(f"all:top-level: status_levels missing '{required}'") + return errs + + +def _check_unique_ids(reg: dict) -> list[str]: + errs: list[str] = [] + seen: set[str] = set() + for lang in reg.get("languages", []): + if not isinstance(lang, dict): + continue + lid = lang.get("id") + if lid in seen: + errs.append(f"all:ids: duplicate id '{lid}'") + if isinstance(lid, str): + seen.add(lid) + return errs + + +def _check_id_kebab(reg: dict) -> list[str]: + errs: list[str] = [] + for lang in reg.get("languages", []): + if not isinstance(lang, dict): + continue + lid = lang.get("id") + if isinstance(lid, str) and not KEBAB_RE.match(lid): + errs.append(f"{lid}:id: not kebab-case") + return errs + + +def _check_status(reg: dict) -> list[str]: + errs: list[str] = [] + levels = reg.get("status_levels", {}) + valid = set(levels.keys()) + for lang in reg.get("languages", []): + if not isinstance(lang, dict): + continue + lid = lang.get("id", "") + st = lang.get("status") + if st not in valid: + errs.append(f"{lid}:status: '{st}' not in status_levels {sorted(valid)}") + return errs + + +def _check_ext_or_marker_collision(reg: dict, key: str, label: str) -> list[str]: + errs: list[str] = [] + owners: dict[str, str] = {} + for lang in reg.get("languages", []): + if not isinstance(lang, dict): + continue + if lang.get("status") not in ("stable", "beta"): + continue + lid = lang.get("id", "") + for ext in lang.get(key, []) or []: + if ext in owners and owners[ext] != lid: + errs.append(f"all:{key}s: '{ext}' owned by '{owners[ext]}' and '{lid}'") + owners[ext] = lid + return errs + + +def _check_extensions_dot_prefix(reg: dict) -> list[str]: + errs: list[str] = [] + for lang in reg.get("languages", []): + if not isinstance(lang, dict): + continue + lid = lang.get("id", "") + for ext in lang.get("extensions", []) or []: + if not isinstance(ext, str) or not ext.startswith("."): + errs.append(f"{lid}:extensions: '{ext}' must start with '.'") + return errs + + +def _check_lint_format_shape(reg: dict) -> list[str]: + errs: list[str] = [] + for lang in reg.get("languages", []): + if not isinstance(lang, dict): + continue + lid = lang.get("id", "") + for field in ("lint", "format"): + val = lang.get(field) + if val is None: + continue + if not isinstance(val, list) or not all(isinstance(s, str) and s for s in val): + errs.append(f"{lid}:{field}: must be list of non-empty strings") + return errs + + +def _check_status_command_presence(reg: dict) -> list[str]: + errs: list[str] = [] + for lang in reg.get("languages", []): + if not isinstance(lang, dict): + continue + lid = lang.get("id", "") + st = lang.get("status") + if st in ("stable", "beta"): + lint = lang.get("lint") or [] + fmt = lang.get("format") or [] + if not lint and not fmt: + errs.append(f"{lid}:commands: stable/beta requires non-empty lint or format") + elif st == "planned": + lint = lang.get("lint") or [] + fmt = lang.get("format") or [] + if lint or fmt: + errs.append(f"{lid}:commands: planned must leave lint/format empty") + return errs + + +def _check_requires_config(reg: dict) -> list[str]: + errs: list[str] = [] + for lang in reg.get("languages", []): + if not isinstance(lang, dict): + continue + lid = lang.get("id", "") + rc = lang.get("requiresConfig") + if rc is None: + continue + if not isinstance(rc, list) or not all(isinstance(s, str) for s in rc): + errs.append(f"{lid}:requiresConfig: must be list of strings") + return errs + + +def _check_since(reg: dict) -> list[str]: + errs: list[str] = [] + for lang in reg.get("languages", []): + if not isinstance(lang, dict): + continue + lid = lang.get("id", "") + st = lang.get("status") + since = lang.get("since") + if st in ("stable", "beta") and not isinstance(since, str) or (st in ("stable", "beta") and since == ""): + errs.append(f"{lid}:since: stable/beta requires non-empty 'since' string") + return errs + + +def check(registry: dict) -> list[str]: + """Return all schema and consistency errors; empty list means valid.""" + errs: list[str] = [] + errs += _check_top_level(registry) + errs += _check_unique_ids(registry) + errs += _check_id_kebab(registry) + errs += _check_status(registry) + errs += _check_ext_or_marker_collision(registry, "extensions", "extension") + errs += _check_ext_or_marker_collision(registry, "markers", "marker") + errs += _check_extensions_dot_prefix(registry) + errs += _check_lint_format_shape(registry) + errs += _check_status_command_presence(registry) + errs += _check_requires_config(registry) + errs += _check_since(registry) + return errs + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--path", default="scripts/languages.json", + help="path to languages.json (default: scripts/languages.json)") + args = parser.parse_args() + path = Path(args.path) + if not path.is_file(): + print(f"ERROR: --path '{path}' does not exist", file=sys.stderr) + return 2 + try: + reg = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + print(f"ERROR: failed to read/parse {path}: {exc}", file=sys.stderr) + return 2 + + langs = reg.get("languages", []) if isinstance(reg, dict) else [] + counts: dict[str, int] = {} + for lang in langs: + if isinstance(lang, dict): + counts[lang.get("status", "unknown")] = counts.get(lang.get("status", "unknown"), 0) + 1 + print(f"INFO: languages.json contains {len(langs)} entries " + f"({counts.get('stable', 0)} stable, {counts.get('beta', 0)} beta, {counts.get('planned', 0)} planned)") + + errs = check(reg) + if not errs: + print("INFO: 11 schema rules passed") + return 0 + for err in errs: + print(f"ERROR: {err}") + print(f"SUMMARY: {len(errs)} errors", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/tests/run_all.py b/tests/run_all.py index c92c25c..ed39055 100644 --- a/tests/run_all.py +++ b/tests/run_all.py @@ -95,6 +95,7 @@ def test_languages(): lint[0] in { "mvn", "cargo", "gradlew", "swiftlint", "dart", "tflint", "ameba", "rubocop", "dotnet", "buf", "mix", "crystal", "elm-review", "elvis", + "ansible-lint", } or (len(lint) > 1 and lint[1] in {"fmt", "rock", "analyze"} and lint[0] in {"zig", "scalafmt"}) or "markdownlint-cli2" in cs diff --git a/tests/test_validate_languages_json.py b/tests/test_validate_languages_json.py new file mode 100644 index 0000000..684c0df --- /dev/null +++ b/tests/test_validate_languages_json.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Unit tests for scripts/validate_languages_json.py. + +These tests intentionally break the schema to verify each rule fires. +A passing validator still needs a passing registry fixture; that baseline +is the current scripts/languages.json (covered by test_clean_registry_passes). +""" +from __future__ import annotations + +import json +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) + +from validate_languages_json import check + +ROOT = Path(__file__).resolve().parents[1] +REGISTRY_PATH = ROOT / "scripts" / "languages.json" + + +def _baseline() -> dict: + return json.loads(REGISTRY_PATH.read_text(encoding="utf-8")) + + +def _mutate(reg: dict, lang_id: str, **patch) -> dict: + """Return a deep-enough copy of reg with the matching entry patched.""" + import copy + reg = copy.deepcopy(reg) + for l in reg["languages"]: + if l.get("id") == lang_id: + for k, v in patch.items(): + l[k] = v + return reg + + +class LanguagesJsonValidatorTest(unittest.TestCase): + def test_clean_registry_passes(self) -> None: + self.assertEqual([], check(_baseline())) + + def test_duplicate_id_fails(self) -> None: + reg = _baseline() + clone = {"id": "java", "name": "Java Clone", "extensions": [".java_clone"], + "markers": [], "status": "stable", "since": "V0.1"} + reg["languages"].append(clone) + errs = check(reg) + self.assertTrue(any("duplicate id 'java'" in e for e in errs), errs) + + def test_invalid_status_fails(self) -> None: + reg = _mutate(_baseline(), "python", status="draft") + errs = check(reg) + self.assertTrue(any("python:status" in e and "draft" in e for e in errs), errs) + + def test_extension_collision_fails(self) -> None: + reg = _baseline() + # Inject a synthetic stable language claiming .rs + reg["languages"].append({ + "id": "rustish", "name": "Rustish", "extensions": [".rs"], + "markers": [], "status": "stable", "since": "V0.1", + }) + errs = check(reg) + self.assertTrue(any("extensions" in e and ".rs" in e for e in errs), errs) + + def test_marker_collision_fails(self) -> None: + reg = _mutate(_baseline(), "go", markers=["pom.xml"]) + errs = check(reg) + self.assertTrue(any("markers" in e and "pom.xml" in e for e in errs), errs) + + def test_planned_with_lint_fails(self) -> None: + # cobol is one of the planned entries; adding a lint violates rule + reg = _mutate(_baseline(), "cobol", lint=["cobol-lint"], format=None) + errs = check(reg) + self.assertTrue(any("cobol:commands" in e and "planned" in e for e in errs), errs) + + def test_stable_with_no_lint_or_format_fails(self) -> None: + reg = _mutate(_baseline(), "python", lint=None, format=None) + errs = check(reg) + self.assertTrue(any("python:commands" in e and "stable" in e for e in errs), errs) + + def test_requiresConfig_must_be_list_of_str(self) -> None: + reg = _mutate(_baseline(), "python", requiresConfig="ruff.toml") + errs = check(reg) + self.assertTrue(any("python:requiresConfig" in e for e in errs), errs) + + def test_extensions_must_start_with_dot(self) -> None: + reg = _mutate(_baseline(), "python", extensions=["py"]) + errs = check(reg) + self.assertTrue(any("python:extensions" in e and "py" in e for e in errs), errs) + + def test_id_must_be_kebab_case(self) -> None: + reg = _baseline() + # Append a bogus id to exercise rule 8 + reg["languages"].append({ + "id": "Ruby_on_Rails", "name": "Rails", "extensions": [".rr"], + "markers": [], "status": "stable", "since": "V0.1", + }) + errs = check(reg) + self.assertTrue(any("Ruby_on_Rails:id" in e for e in errs), errs) + + +if __name__ == "__main__": + unittest.main()