From 9d33573c05384c4af7c0ed27d072daef303285ce Mon Sep 17 00:00:00 2001 From: Seungpyo1007 Date: Thu, 12 Nov 2026 02:00:36 +0900 Subject: [PATCH 1/8] chore: bump TechEngine submodule to ff4e11e This brings in GetTechAPI/TechEngine#134, the engine's own cleanup. The engine no longer carries TechAPI as a submodule, so the two repos stop pointing at each other. app/dump_check.py, app/verify/baseline.py and passmark_ids.json are gone; nothing here called them. integrity_check.py now reads the directory app.data_root resolves when no path is given, instead of a Windows path that existed on one machine. collect.yml's three `uses:` move to the same commit. In the reusable workflows only a comment changed; the collectors, the validator and the dump code are untouched. --- .github/workflows/collect.yml | 6 +++--- TechEngine | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/collect.yml b/.github/workflows/collect.yml index fe9465691667..cdc073e8f466 100644 --- a/.github/workflows/collect.yml +++ b/.github/workflows/collect.yml @@ -74,7 +74,7 @@ jobs: inputs.task == 'ingest' permissions: contents: read - uses: GetTechAPI/TechEngine/.github/workflows/ingest.yml@45090a02409b2418d156c6c0af77395e97d32050 # main, TechEngine #133 + uses: GetTechAPI/TechEngine/.github/workflows/ingest.yml@ff4e11ec294e2f7c428bcfc3a3298ce8163e7d6a # main, TechEngine #134 with: category: ${{ inputs.category }} limit: ${{ inputs.limit }} @@ -86,7 +86,7 @@ jobs: inputs.task == 'refresh' permissions: contents: read - uses: GetTechAPI/TechEngine/.github/workflows/refresh.yml@45090a02409b2418d156c6c0af77395e97d32050 # main, TechEngine #133 + uses: GetTechAPI/TechEngine/.github/workflows/refresh.yml@ff4e11ec294e2f7c428bcfc3a3298ce8163e7d6a # main, TechEngine #134 with: sleep: ${{ inputs.sleep }} @@ -96,7 +96,7 @@ jobs: inputs.task == 'promote' permissions: contents: read - uses: GetTechAPI/TechEngine/.github/workflows/verify-promote.yml@45090a02409b2418d156c6c0af77395e97d32050 # main, TechEngine #133 + uses: GetTechAPI/TechEngine/.github/workflows/verify-promote.yml@ff4e11ec294e2f7c428bcfc3a3298ce8163e7d6a # main, TechEngine #134 with: apply: ${{ inputs.apply }} max_urls: ${{ inputs.max_urls }} diff --git a/TechEngine b/TechEngine index 45090a02409b..ff4e11ec294e 160000 --- a/TechEngine +++ b/TechEngine @@ -1 +1 @@ -Subproject commit 45090a02409b2418d156c6c0af77395e97d32050 +Subproject commit ff4e11ec294e2f7c428bcfc3a3298ce8163e7d6a From 90865b3bdb20ba7c2f369c0bc93095fc3100ae22 Mon Sep 17 00:00:00 2001 From: Seungpyo1007 Date: Thu, 12 Nov 2026 02:01:01 +0900 Subject: [PATCH 2/8] docs: stop explaining a submodule loop that no longer exists Since the pin moved to ff4e11e, TechEngine carries no submodule, so a recursive checkout has nothing to loop on. The comments in pages.yml, pr-checks.yml, final-check.yml and build-api.sh, and two notes in the README, still warned about it. The checkouts stay plain and non-recursive; only the wording changes. --- .github/scripts/build-api.sh | 2 +- .github/workflows/final-check.yml | 5 +++-- .github/workflows/pages.yml | 4 ++-- .github/workflows/pr-checks.yml | 4 ++-- README.md | 6 ++---- 5 files changed, 10 insertions(+), 11 deletions(-) diff --git a/.github/scripts/build-api.sh b/.github/scripts/build-api.sh index eef53f63112c..a6669f0e2a6a 100755 --- a/.github/scripts/build-api.sh +++ b/.github/scripts/build-api.sh @@ -4,7 +4,7 @@ # (data/_verify/status.json) with the pinned TechEngine, and check the result. # pages.yml runs this before publishing. # -# Needs the TechEngine submodule checked out (never recursively) and its +# Needs the TechEngine submodule checked out and its # dependencies installed: python -m pip install -e ./TechEngine set -euo pipefail diff --git a/.github/workflows/final-check.yml b/.github/workflows/final-check.yml index fc0335d011f2..099029f5a998 100644 --- a/.github/workflows/final-check.yml +++ b/.github/workflows/final-check.yml @@ -35,8 +35,9 @@ jobs: env: PYTHONIOENCODING: utf-8 steps: - # The merge commit, as pages.yml would build it after merging. Not - # recursive: TechEngine carries TechAPI as its own submodule. + # The merge commit, as pages.yml would build it after merging. A plain, + # non-recursive submodule checkout is all it needs: TechEngine has no + # submodules of its own. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 2 diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 0fb5531eb6e8..5ed8555650d8 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -30,8 +30,8 @@ jobs: steps: # Full history: the dump stamps created_at/updated_at from git log of # data/ (a shallow clone stamps every record with the tip time), and the - # growth chart reads old commits. Submodules are not recursive: TechEngine - # carries TechAPI as its own submodule. + # growth chart reads old commits. A plain, non-recursive submodule + # checkout is all it needs: TechEngine has no submodules of its own. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index e0af26fba5fa..6028d82bc9e2 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -29,8 +29,8 @@ jobs: steps: # The pull_request ref is GitHub's merge commit. Depth 2 brings both # parents, so HEAD^1 is the base the PR merges into: no deepening, no - # second checkout. submodules: true is NOT recursive (TechEngine carries - # TechAPI as its own submodule; recursion re-clones ~1.8 GB). + # second checkout. submodules: true is a plain, non-recursive checkout, + # which is all it needs: TechEngine has no submodules of its own. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 2 diff --git a/README.md b/README.md index b47f2df7a8ef..6c5aec2133c1 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ only the hand-made `site/public/v1/games/index.json` pointer is. To preview the site with the API locally: ```bash -git submodule update --init TechEngine # never --recursive +git submodule update --init TechEngine python -m pip install -e ./TechEngine bash .github/scripts/build-api.sh # validates, then writes site/public/v1 cd site && npm ci && npm run dev @@ -99,9 +99,7 @@ This repo follows git-flow: A release is a PR from `develop` to `main`; merging it deploys the site. The TechEngine pin and the pinned action SHAs are bumped by hand, in a PR into -`develop` that pr-checks validates like any other. Dependabot can't do it: -TechEngine carries TechAPI as its own submodule, and Dependabot's recursive -clone runs out of disk on that loop. +`develop` that pr-checks validates like any other; Dependabot is not used. ## Contributing From dc054d2ea0e78b10da744dbd88557271fb3f0c89 Mon Sep 17 00:00:00 2001 From: Seungpyo1007 Date: Thu, 12 Nov 2026 02:03:27 +0900 Subject: [PATCH 3/8] docs: describe every data category and map the repository in the README The Layout section listed five of the thirteen categories. It now has a table with each category's real path pattern, file count and an example, notes on the year, country, base-model and prefix folders, and what data/_verify/ is. A short repository map names data/, app/, site/, TechEngine/ and each workflow and script under .github/, and a new section points at the related repositories. --- README.md | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 65 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 6c5aec2133c1..ff3883bf98e9 100644 --- a/README.md +++ b/README.md @@ -21,15 +21,65 @@ static-dump generator live in ## Layout +### Repository map + ```text -data/brand//.json # e.g. data/brand/kr/samsung.json -data/soc///.json # data/soc/qualcomm/2024/snapdragon-8-elite.json -data/smartphone///.json # data/smartphone/samsung/2025/galaxy-s25.json -data/gpu////.json # data/gpu/nvidia/2025/consumer/geforce-rtx-5090.json -data/cpu////.json # data/cpu/intel/2023/consumer/core-i9-14900k.json +data/ # the dataset: one JSON file per record (categories below) +app/ # the bundled validator, standard library only (python -m app.validate) +site/ # the Astro site that GitHub Pages serves, with the generated API +TechEngine/ # the engine, a submodule pinned to one commit: full validator, + # integrity gate, API dump, collectors and verification +.github/ + workflows/ + pr-checks.yml # read-only checks on every PR into develop or main + final-check.yml # full build and verification of a PR labelled final-check + collect.yml # runs one engine collector by hand; uploads a patch + pages.yml # builds the API and the site from main and deploys them + scripts/ + build-api.sh # validates, then generates site/public/v1 and openapi.json + check_dump.py # compares the generated API with data/ + open-collect-pr.sh # opens the PR for one collect.yml run ``` -All paths use singular folder names. Slugs are kebab-case and unique within each category. +### Data + +Every record is one JSON file named `.json`. Folder names are singular, +slugs are kebab-case and unique within their category. Counts are as of +November 2026. + +| Category | Path under `data/` | Files | Example | +| --- | --- | ---: | --- | +| `brand` | `brand//.json` | 3,022 | `brand/kr/samsung.json` | +| `soc` | `soc///.json` | 2,117 | `soc/qualcomm/2024/snapdragon-8-elite.json` | +| `cpu` | `cpu////.json` | 4,517 | `cpu/intel/2023/consumer/core-i9-14900k.json` | +| `gpu` | `gpu////.json` | 2,076 | `gpu/nvidia/2025/consumer/geforce-rtx-5090.json` | +| `smartphone` | `smartphone///.json` or `smartphone////.json` | 92,824 | `smartphone/samsung/2025/galaxy-s25.json` | +| `tablet` | `tablet////.json` | 3,305 | `tablet/huawei/2020/matepad-t8/matepad-t8.json` | +| `watch` | `watch////.json` | 418 | `watch/samsung/2024/galaxy-watch-fe/…` | +| `pda` | `pda////.json` | 112 | `pda/hp/2000/ipaq-h3600/ipaq-h3600-base.json` | +| `laptop` | `laptop////.json` | 1,966 | `laptop/dell/2017/dell-inspiron-5567/…` | +| `monitor` | `monitor////.json` | 231 | `monitor/samsung/2023/samsung-m50b/…` | +| `device_catalog` | `device_catalog//.json` | 20,366 | `device_catalog/kazam/kazam-thunder-345-lte.json` | +| `software` | `software//.json` | 42,493 | `software/sw/sweble.json` | +| `website` | `website//.json` | 39,930 | `website/pc/pc-gamer-uk.json` | + +- `` is the release year, as in the record's `release_date`. +- `` is the brand's ISO 3166 country code in lower case. A brand with + no sourced country lives under `brand/unknown/`; that is most of them (2,815). +- `` is a coarse folder split. A CPU record's own `segment` + field is finer: `desktop`, `laptop`, `hedt` or `server`. +- `` groups the variants of one model (memory, storage, region). + About a third of the smartphones (31,093) and 44 laptops sit directly in the + year folder instead. +- `` is the first two characters of the slug, or the whole slug when it + is one character long. +- `device_catalog` lists device identities (model numbers, codenames) whose + release date is unknown, so it has no year folder. +- `data/_verify/` is not a category. It holds the verification ledger + (`ledger.jsonl`), the status aggregate (`status.json`) and the caches under + `state/` that TechEngine's verify tools write. Don't edit it by hand. + +### Site The Astro site lives under `site/` and is the deploy target for GitHub Pages. [`pages.yml`](.github/workflows/pages.yml) generates the static JSON API from @@ -101,6 +151,15 @@ A release is a PR from `develop` to `main`; merging it deploys the site. The TechEngine pin and the pinned action SHAs are bumped by hand, in a PR into `develop` that pr-checks validates like any other; Dependabot is not used. +## Related repositories + +| Repository | What it holds | +| --- | --- | +| [TechEngine](https://github.com/GetTechAPI/TechEngine) | The engine pinned here as a submodule: validation, the static API generator, the collectors and the verification tiers. | +| [game-catalog](https://github.com/GetTechAPI/game-catalog) | Video game records. They moved out of this repo in September 2026; `site/public/v1/games/index.json` points there. | +| [cpu-engineering-samples](https://github.com/GetTechAPI/cpu-engineering-samples) | Engineering-sample CPUs, kept apart from the retail CPUs in `data/cpu/`. | +| [TechMachine](https://github.com/GetTechAPI/TechMachine) | The organisation's repository health check and the scaffolder for new satellite data repos. | + ## Contributing Open a PR **against `develop`** with the new/updated JSON file — that is also From 47e84a395de13bde00353cb8481763ffe0afe5d7 Mon Sep 17 00:00:00 2001 From: Seungpyo1007 Date: Thu, 12 Nov 2026 02:03:27 +0900 Subject: [PATCH 4/8] docs(data): correct the path patterns in data/README.md brand/ has a country folder, and soc/ and smartphone/ have a year folder; the file showed none of them and left out eight categories. It now lists every category, explains _verify/, and points at the table in the root README. The note about curation no longer says every record is hand-verified: `verified` on the record says that. --- data/README.md | 39 +++++++++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/data/README.md b/data/README.md index ed052783b062..ed13d04d6561 100644 --- a/data/README.md +++ b/data/README.md @@ -1,24 +1,39 @@ # data/ — curated dataset -These JSON files are the **curated, validated** records the API serves. Layout is -singular folder names, organised by brand: +These JSON files are the **curated, validated** records the API serves: one +file per record, named `.json`, in singular category folders. ``` -brand/.json -soc//.json -smartphone//.json -gpu////.json # GPUs are split by release year then segment (consumer | enterprise) -cpu////.json # CPUs split by release year then segment (consumer | enterprise) +brand//.json # country code in lower case, or unknown/ +soc///.json +cpu////.json +gpu////.json +smartphone///.json # or //.json +tablet////.json # watch/, pda/, laptop/ and monitor/ are the same +device_catalog//.json # device identities with no known release date +software//.json # prefix = first two characters of the slug +website//.json +_verify/ # not records: see below ``` -GPU `` is: +The [root README](../README.md#layout) has the same table with file counts and +an example for each category. + +The `consumer` / `enterprise` folder is a coarse split. For GPUs: * `consumer` — gaming/desktop & laptop dGPUs (GeForce GTX/RTX, Radeon RX/R9/HD, Arc, Voodoo, MTT S60/S70/S80, …) * `enterprise` — datacenter compute, AI accelerators, workstation pro cards (Tesla, A100/H100/B200, Quadro/RTX A/RTX PRO, Radeon Pro W, Instinct MI, MTT S3000/S4000, Vega Frontier, AI-focused TITAN V/RTX, …) +For CPUs, `enterprise` holds server and most HEDT parts. The record's own +`segment` field is finer: `desktop`, `laptop`, `hedt` or `server`. + +`_verify/` holds the verification ledger (`ledger.jsonl`), the status aggregate +(`status.json`) and the caches under `state/`. TechEngine's verify tools write +them; don't edit them by hand. + > ⚠️ **This is a curated subset — NOT an exhaustive list of every device/chip.** -> It is hand-verified and intentionally partial. Breadth is expanded out-of-band -> through an internal pipeline that publishes curated records here after review; -> each record carries `source_urls`. Don't assume a device is missing-by-error — -> it may simply not be curated yet. +> It is intentionally partial. New records come from TechEngine's collectors and +> land here through a reviewed PR; each record carries `source_urls`, and +> `verified` says whether it has passed verification. Don't assume a device is +> missing-by-error — it may simply not be curated yet. Validate after edits: `python -m app.validate`. Add only real, sourced models. From dcb850edc529dba1b4649df8a0e312876dd689ed Mon Sep 17 00:00:00 2001 From: Seungpyo1007 Date: Thu, 12 Nov 2026 02:04:13 +0900 Subject: [PATCH 5/8] chore(github): bring the issue and PR templates up to date - data_addition offered four categories; it and data_correction now offer all thirteen. data_correction asks for the category because a slug is unique only within one. - bug_report asked for a request_id and showed api.techapi.dev. There is no live server: the example is now a static API URL and the field is gone. - feature_request no longer cites a decision record and an app that nothing in this repo explains. - The PR checklist drops a review step that is not used, and CODEOWNERS drops a finished TODO. - config.yml sends engine and game issues to their own repositories. Blank issues stay enabled. --- .github/CODEOWNERS | 1 - .github/ISSUE_TEMPLATE/bug_report.yml | 11 +++-------- .github/ISSUE_TEMPLATE/config.yml | 8 ++++++++ .github/ISSUE_TEMPLATE/data_addition.yml | 17 +++++++++++++---- .github/ISSUE_TEMPLATE/data_correction.yml | 21 +++++++++++++++++++++ .github/ISSUE_TEMPLATE/feature_request.yml | 2 +- .github/pull_request_template.md | 2 +- 7 files changed, 47 insertions(+), 15 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/config.yml diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 65464a5fd968..966fb6224c07 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,5 +1,4 @@ # Default owners for everything in the repo. -# TODO: replace with the maintainer's GitHub handle or org team before Phase 1. * @GetTechAPI/maintainers /data/ @GetTechAPI/maintainers diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 7406fd4504ed..784ab24bf286 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,5 +1,5 @@ name: "🐛 Bug report" -description: Report a bug in the API or tooling +description: Report a bug in the static API, the site or the validator labels: ["type:bug"] body: - type: textarea @@ -13,9 +13,9 @@ body: id: reproduce attributes: label: Steps to reproduce - description: Include the exact request (curl) where possible. + description: Include the exact URL or command where possible. placeholder: | - curl https://api.techapi.dev/v1/smartphones/galaxy-s25 + curl https://gettechapi.github.io/TechAPI/v1/smartphones/galaxy-s25/index.json validations: required: true - type: textarea @@ -24,8 +24,3 @@ body: label: Expected behavior validations: required: true - - type: input - id: request-id - attributes: - label: request_id (if any) - description: The `request_id` from the error response helps us trace it. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000000..9eff584976ad --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: Collectors, verification or the API generator + url: https://github.com/GetTechAPI/TechEngine/issues + about: The engine that validates and publishes this data has its own repository. + - name: Video games + url: https://github.com/GetTechAPI/game-catalog/issues + about: Game records moved to the game-catalog repository. diff --git a/.github/ISSUE_TEMPLATE/data_addition.yml b/.github/ISSUE_TEMPLATE/data_addition.yml index 5aba15296646..0b5f4e799175 100644 --- a/.github/ISSUE_TEMPLATE/data_addition.yml +++ b/.github/ISSUE_TEMPLATE/data_addition.yml @@ -1,5 +1,5 @@ name: "📥 Data addition" -description: Request a new device, SoC, or GPU +description: Request a record that is missing from the dataset labels: ["type:data", "data:missing"] body: - type: dropdown @@ -7,10 +7,19 @@ body: attributes: label: Category options: - - smartphone + - brand - soc + - cpu - gpu - - brand + - smartphone + - tablet + - watch + - pda + - laptop + - monitor + - device_catalog + - software + - website validations: required: true - type: input @@ -24,6 +33,6 @@ body: id: sources attributes: label: Source URLs (at least one) - description: Manufacturer spec sheet preferred (§9.2). + description: The manufacturer's product page or datasheet is preferred; a Wikipedia article also works. validations: required: true diff --git a/.github/ISSUE_TEMPLATE/data_correction.yml b/.github/ISSUE_TEMPLATE/data_correction.yml index e2ed9eb227f0..7dea87d8b9b4 100644 --- a/.github/ISSUE_TEMPLATE/data_correction.yml +++ b/.github/ISSUE_TEMPLATE/data_correction.yml @@ -2,6 +2,27 @@ name: "✏️ Data correction" description: Correct an existing spec value labels: ["type:data", "data:incorrect"] body: + - type: dropdown + id: category + attributes: + label: Category + description: Slugs are unique only within a category. + options: + - brand + - soc + - cpu + - gpu + - smartphone + - tablet + - watch + - pda + - laptop + - monitor + - device_catalog + - software + - website + validations: + required: true - type: input id: slug attributes: diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 70874af96455..d1d638ea6dac 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -20,5 +20,5 @@ body: attributes: label: Scope check options: - - label: This is generalizable to all consumers (not TechPicks-specific, see ADR-008). + - label: This is useful to anyone who reads the data, not only to one app or site. required: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index e0a3291b1630..09ca8ec93fb2 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -15,4 +15,4 @@ - [ ] Files live at the correct `data//<...>/.json` path - [ ] Slugs are kebab-case and unique within the category - [ ] `source_urls` cites at least one canonical reference -- [ ] Sidebar filled in by hand (nothing fills it automatically): assignees, labels, milestone, projects, Copilot review, and the tracker under Development +- [ ] Sidebar filled in by hand (nothing fills it automatically): assignees, labels, milestone, projects, and the tracker under Development From 53c95495db292ffd66e5bbaa4f467b9be04fc728 Mon Sep 17 00:00:00 2001 From: Seungpyo1007 Date: Thu, 12 Nov 2026 02:05:16 +0900 Subject: [PATCH 6/8] chore(validate): drop the game checks left over from the catalog split Games moved to the game-catalog repository and data/game no longer exists, so GAME_REQUIRED and the loop over an always-empty list checked nothing. The engine's validator has no game branch either. The docstring named a validate-data.yml workflow this repo does not have; it now names what runs the validator and says which document the section numbers in the messages belong to. A comment cited a decision record that is about something else. No check changes: the full scan passes as before. Line endings are untouched (still CRLF). --- app/validate.py | 36 +++++++++++------------------------- 1 file changed, 11 insertions(+), 25 deletions(-) diff --git a/app/validate.py b/app/validate.py index 969e9e6ffcea..e6380173242c 100644 --- a/app/validate.py +++ b/app/validate.py @@ -1,8 +1,14 @@ -"""Validate seed JSON against the schema and conventions (§9.3, §15.3). +"""Validate the records under ``data/`` with the standard library only. -Checks: required fields, slug convention (§14.1), value ranges/units (§14.3), -and foreign-key integrity by slug. Run with ``python -m app.validate``; -exits non-zero on the first failure set (used by CI ``validate-data.yml``). +Checks: required fields, the slug convention, value ranges and units, the +folder a record lives in, and that references by slug (brand, soc, cpu, gpu) +resolve. Run with ``python -m app.validate``; it prints every issue and exits +non-zero if there is any. ``pr-checks.yml`` runs it on every PR and +``.github/scripts/build-api.sh`` runs it before the API is generated. + +Section numbers in the messages (§14.1, §14.2) are those of +``docs/SPEC.md`` in TechEngine, whose ``app/validate.py`` is the full +validator. """ from __future__ import annotations @@ -35,7 +41,7 @@ "defunct", } COUNTRY_RE = re.compile(r"^[A-Z]{2}$") -# Media links may only point at free Wikimedia Commons files (ADR-017), never third-party hotlinks. +# Media links may only point at free Wikimedia Commons files, never third-party hotlinks. COMMONS_UPLOAD_RE = re.compile(r"https://upload\.wikimedia\.org/wikipedia/commons/\S+") COMMONS_FILEPATH_RE = re.compile(r"https://commons\.wikimedia\.org/wiki/Special:FilePath/\S+") SOC_REQUIRED = {"slug", "name", "manufacturer", "release_date", "process_nm", "gpu_name"} @@ -108,13 +114,6 @@ "verified", } -GAME_REQUIRED = { - "slug", - "name", - "source_urls", - "verified", -} - SOFTWARE_REQUIRED = { "slug", "name", @@ -327,7 +326,6 @@ def validate() -> list[str]: cpus = _load("cpu") laptops = _load("laptop") monitors = _load("monitor") - games = _load("game") software = _load("software") websites = _load("website") catalog = _load("device_catalog") @@ -348,7 +346,6 @@ def validate() -> list[str]: ("cpu", cpus), ("laptop", laptops), ("monitor", monitors), - ("game", games), ("software", software), ("website", websites), ("device_catalog", catalog), @@ -508,17 +505,6 @@ def validate() -> list[str]: errors.append(f"{fname}: brand '{rec.get('brand')}' not a known brand") _check_variant_path(fname, rec, "monitor", errors, allow_flat=True) - for fname, rec in games: - _check_required(fname, rec, GAME_REQUIRED, errors) - _check_source_urls(fname, rec, errors) - _check_slug(fname, rec.get("slug"), errors) - if rec.get("release_date") is not None: - _check_date(fname, rec["release_date"], errors) - if rec.get("rating") is not None: - _check_range(fname, "rating", rec.get("rating"), 0, 5, errors) - if rec.get("metacritic") is not None: - _check_range(fname, "metacritic", rec.get("metacritic"), 0, 100, errors) - for fname, rec in software: _check_required(fname, rec, SOFTWARE_REQUIRED, errors) _check_source_urls(fname, rec, errors) From e039d13428b0ffc5e27ecad2f8222de78a912e98 Mon Sep 17 00:00:00 2001 From: Seungpyo1007 Date: Thu, 12 Nov 2026 02:05:49 +0900 Subject: [PATCH 7/8] chore: add .editorconfig and .gitattributes, store validate.py with LF .editorconfig is the same as TechEngine's: UTF-8, LF, final newline, 4 spaces for Python and 2 for YAML, JSON, Astro, Markdown and mjs. .gitattributes sets `* text=auto eol=lf`, so a Windows checkout no longer turns files into CRLF. app/validate.py was the only tracked text file stored with CRLF (all of data/ is LF already). It is renormalised here: every line shows as changed, but `git diff --ignore-cr-at-eol` against the parent is empty. --- .editorconfig | 18 + .gitattributes | 3 + app/validate.py | 1230 +++++++++++++++++++++++------------------------ 3 files changed, 636 insertions(+), 615 deletions(-) create mode 100644 .editorconfig create mode 100644 .gitattributes diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000000..409a406db5f5 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.py] +indent_style = space +indent_size = 4 + +[*.{yml,yaml,json,astro,md,mjs}] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000000..e61085f263d3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Every tracked text file is stored with LF; keep working trees the same on +# Windows and macOS. +* text=auto eol=lf diff --git a/app/validate.py b/app/validate.py index e6380173242c..0d4c8f26acc3 100644 --- a/app/validate.py +++ b/app/validate.py @@ -1,615 +1,615 @@ -"""Validate the records under ``data/`` with the standard library only. - -Checks: required fields, the slug convention, value ranges and units, the -folder a record lives in, and that references by slug (brand, soc, cpu, gpu) -resolve. Run with ``python -m app.validate``; it prints every issue and exits -non-zero if there is any. ``pr-checks.yml`` runs it on every PR and -``.github/scripts/build-api.sh`` runs it before the API is generated. - -Section numbers in the messages (§14.1, §14.2) are those of -``docs/SPEC.md`` in TechEngine, whose ``app/validate.py`` is the full -validator. -""" - -from __future__ import annotations - -import json -import re -import sys -from collections.abc import Iterator -from pathlib import Path -from typing import Any - -DATA_DIR = Path(__file__).resolve().parent.parent / "data" - -SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") - -# country is optional: many device makers have no sourced country, and it is -# never guessed. A brand without one lives under brand/unknown/. -BRAND_REQUIRED = {"slug", "name", "categories", "source_urls"} -BRAND_UNKNOWN_COUNTRY_DIR = "unknown" -BRAND_CATEGORIES = { - "smartphone-oem", - "soc-designer", - "cpu-designer", - "gpu-designer", - "ip-licensor", - "aib-partner", - "pc-oem", - "chipset-maker", - "sub-brand", - "defunct", -} -COUNTRY_RE = re.compile(r"^[A-Z]{2}$") -# Media links may only point at free Wikimedia Commons files, never third-party hotlinks. -COMMONS_UPLOAD_RE = re.compile(r"https://upload\.wikimedia\.org/wikipedia/commons/\S+") -COMMONS_FILEPATH_RE = re.compile(r"https://commons\.wikimedia\.org/wiki/Special:FilePath/\S+") -SOC_REQUIRED = {"slug", "name", "manufacturer", "release_date", "process_nm", "gpu_name"} -PHONE_REQUIRED = { - "slug", - "name", - "brand", - "soc", - "release_date", - "ram_gb", - "os", -} - -MOBILE_DEVICE_REQUIRED = { - "slug", - "name", - "brand", - "release_date", - "ram_gb", - "os", - "source_urls", - "verified", -} - -GPU_REQUIRED = { - "slug", - "name", - "manufacturer", - "architecture", - "release_date", - "memory_gb", - "memory_type", - "memory_bus_bit", - "base_clock_mhz", - "boost_clock_mhz", - "tdp_w", - "pcie_version", -} - -CPU_REQUIRED = { - "slug", - "name", - "manufacturer", - "release_date", - "segment", - "architecture", - "cores", - "threads", -} - -LAPTOP_REQUIRED = { - "slug", - "name", - "brand", - "release_date", - "ram_gb", - "os", - "source_urls", - "verified", -} - -MONITOR_REQUIRED = { - "slug", - "name", - "brand", - "release_date", - "size_inch", - "resolution", - "source_urls", - "verified", -} - -SOFTWARE_REQUIRED = { - "slug", - "name", - "source_urls", - "verified", -} - -WEBSITE_REQUIRED = { - "slug", - "name", - "source_urls", - "verified", -} - -DEVICE_CATALOG_REQUIRED = { - "slug", - "name", - "brand", - "source_urls", - "verified", -} -FORM_FACTORS = {"phone", "tablet", "watch", "tv", "other"} -# Categories a catalog entry can be promoted to (``promoted_to`` = "/"). -PROMOTION_TARGETS = {"smartphone", "tablet", "watch", "pda"} -DATE_PRECISIONS = {"day", "month", "year", "year_estimated"} -RELEASE_YEAR_SOURCES = {"model_code", "record"} -RESOLUTION_RE = re.compile(r"^[1-9]\d{1,4}x[1-9]\d{1,4}$") - -DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") - - -def _load(subdir: str) -> list[tuple[str, dict[str, Any]]]: - path = DATA_DIR / subdir - if not path.exists(): - return [] - return [ - (str(f.relative_to(DATA_DIR)), json.loads(f.read_text(encoding="utf-8-sig"))) - for f in sorted(path.rglob("*.json")) # recurse into brand subfolders - ] - - -def _check_required( - name: str, record: dict[str, Any], required: set[str], errors: list[str] -) -> None: - missing = required - record.keys() - if missing: - errors.append(f"{name}: missing required fields {sorted(missing)}") - - -def _check_slug(name: str, slug: object, errors: list[str]) -> None: - if not isinstance(slug, str) or not SLUG_RE.match(slug): - errors.append(f"{name}: invalid slug '{slug}' (must be kebab-case, §14.1)") - - -def _check_range( - name: str, field: str, value: object, lo: float, hi: float, errors: list[str] -) -> None: - if value is None: - return - if not isinstance(value, (int, float)) or not (lo <= value <= hi): - errors.append(f"{name}: {field}={value} out of range [{lo}, {hi}]") - - -def _check_date(name: str, value: object, errors: list[str]) -> None: - if not isinstance(value, str) or not DATE_RE.match(value): - errors.append(f"{name}: release_date '{value}' must be ISO 8601 YYYY-MM-DD (§14.2)") - - -def _check_string_list( - name: str, record: dict[str, Any], field: str, errors: list[str] -) -> None: - """Optional list field: unique, non-empty strings (absent = []).""" - if field not in record: - return - value = record[field] - if not isinstance(value, list) or not all( - isinstance(item, str) and item.strip() for item in value - ): - errors.append(f"{name}: {field} must be a list of non-empty strings") - elif len(set(value)) != len(value): - errors.append(f"{name}: {field} contains duplicates") - - -def _check_identity_fields(name: str, record: dict[str, Any], errors: list[str]) -> None: - """model_numbers / codenames / release_date_precision on device records.""" - _check_string_list(name, record, "model_numbers", errors) - _check_string_list(name, record, "codenames", errors) - precision = record.get("release_date_precision") - if precision is not None and precision not in DATE_PRECISIONS: - errors.append( - f"{name}: release_date_precision '{precision}' not in {sorted(DATE_PRECISIONS)}" - ) - - -def _check_unique_slugs( - category: str, records: list[tuple[str, dict[str, Any]]], errors: list[str] -) -> None: - """Each category's `slug` must be unique — seed/dump load into a UNIQUE column.""" - seen: dict[str, str] = {} - for fname, rec in records: - slug = rec.get("slug") - if not isinstance(slug, str): - continue - if slug in seen: - errors.append( - f"{fname}: duplicate {category} slug '{slug}' (also in {seen[slug]})" - ) - else: - seen[slug] = fname - - -def _check_source_urls(name: str, record: dict[str, Any], errors: list[str]) -> None: - urls = record.get("source_urls") - if not isinstance(urls, list) or not urls or not all( - isinstance(url, str) and url.startswith(("http://", "https://")) for url in urls - ): - errors.append(f"{name}: source_urls must be a non-empty list of http(s) URL strings") - - -def _check_storage_options_gb(name: str, record: dict[str, Any], errors: list[str]) -> None: - values = record.get("storage_options_gb") - if values is None: - return - if not isinstance(values, list): - errors.append(f"{name}: storage_options_gb must be a list of integer GB values") - return - bad = [value for value in values if not isinstance(value, int) or value < 1] - if bad: - errors.append(f"{name}: storage_options_gb contains invalid integer GB values {bad}") - - -def _image_urls(value: Any) -> Iterator[Any]: - """Every ``image_url`` at any depth; ``raw_merged_records`` keep merged-in copies.""" - if isinstance(value, dict): - for key, item in value.items(): - if key == "image_url": - yield item - else: - yield from _image_urls(item) - elif isinstance(value, list): - for item in value: - yield from _image_urls(item) - - -def _check_media(name: str, record: dict[str, Any], errors: list[str]) -> None: - """Photos and logos may only link Wikimedia Commons files; photos carry their credit.""" - if any( - url is not None and not (isinstance(url, str) and COMMONS_UPLOAD_RE.fullmatch(url)) - for url in _image_urls(record) - ): - errors.append( - f"{name}: image_url must be an upload.wikimedia.org/wikipedia/commons file or null" - ) - elif record.get("image_url") is not None and not ( - record.get("image_license") and record.get("image_attribution") - ): - errors.append(f"{name}: image_url needs image_license and image_attribution") - logo = record.get("logo_url") - if logo is not None and not ( - isinstance(logo, str) - and (COMMONS_UPLOAD_RE.fullmatch(logo) or COMMONS_FILEPATH_RE.fullmatch(logo)) - ): - errors.append(f"{name}: logo_url must be a Wikimedia Commons file or null") - - -def _check_variant_path( - fname: str, - rec: dict[str, Any], - category: str, - errors: list[str], - *, - allow_flat: bool = False, -) -> None: - parts = Path(fname).parts - if allow_flat and len(parts) == 4: - return - if len(parts) != 5: - errors.append( - f"{fname}: {category} variants must live at " - f"'{category}////.json'" - ) - return - _, brand, year, base_model_slug, filename = parts - if rec.get("brand") != brand: - errors.append(f"{fname}: lives in brand '{brand}' but brand='{rec.get('brand')}'") - release_year = str(rec.get("release_date", ""))[:4] - if release_year and year != release_year: - errors.append( - f"{fname}: lives in year '{year}' but release_date starts with '{release_year}'" - ) - if rec.get("base_model_slug") and rec.get("base_model_slug") != base_model_slug: - errors.append( - f"{fname}: lives under base '{base_model_slug}' but " - f"base_model_slug='{rec.get('base_model_slug')}'" - ) - if filename != f"{rec.get('slug')}.json": - errors.append(f"{fname}: filename must match slug '{rec.get('slug')}'") - - -def validate() -> list[str]: - errors: list[str] = [] - - brands = _load("brand") - socs = _load("soc") - phones = _load("smartphone") - tablets = _load("tablet") - watches = _load("watch") - pdas = _load("pda") - gpus = _load("gpu") - cpus = _load("cpu") - laptops = _load("laptop") - monitors = _load("monitor") - software = _load("software") - websites = _load("website") - catalog = _load("device_catalog") - - brand_slugs = {rec["slug"] for _, rec in brands if "slug" in rec} - soc_slugs = {rec["slug"] for _, rec in socs if "slug" in rec} - cpu_slugs = {rec["slug"] for _, rec in cpus if "slug" in rec} - gpu_slugs = {rec["slug"] for _, rec in gpus if "slug" in rec} - - for category, records in ( - ("brand", brands), - ("soc", socs), - ("smartphone", phones), - ("tablet", tablets), - ("watch", watches), - ("pda", pdas), - ("gpu", gpus), - ("cpu", cpus), - ("laptop", laptops), - ("monitor", monitors), - ("software", software), - ("website", websites), - ("device_catalog", catalog), - ): - _check_unique_slugs(category, records, errors) - for fname, rec in records: - _check_media(fname, rec, errors) - - for fname, rec in brands: - _check_required(fname, rec, BRAND_REQUIRED, errors) - _check_source_urls(fname, rec, errors) - _check_slug(fname, rec.get("slug"), errors) - if "founded_year" in rec: - _check_range(fname, "founded_year", rec["founded_year"], 1800, 2100, errors) - country = rec.get("country") - if country is not None and not (isinstance(country, str) and COUNTRY_RE.match(country)): - errors.append(f"{fname}: country '{country}' must be ISO 3166 alpha-2 (e.g. 'KR')") - cats = rec.get("categories") - if not isinstance(cats, list) or not cats: - errors.append(f"{fname}: categories must be a non-empty list") - else: - bad = [c for c in cats if c not in BRAND_CATEGORIES] - if bad: - errors.append( - f"{fname}: invalid categories {bad}; allowed = {sorted(BRAND_CATEGORIES)}" - ) - if len(set(cats)) != len(cats): - errors.append(f"{fname}: categories contains duplicates") - # Path convention: brand//.json - parts = Path(fname).parts - folder = country.lower() if isinstance(country, str) else BRAND_UNKNOWN_COUNTRY_DIR - if len(parts) != 3: - errors.append( - f"{fname}: must live at 'brand//.json' " - f"(got {len(parts) - 1} subpath components)" - ) - elif parts[1] != folder: - errors.append( - f"{fname}: lives in '{parts[1]}/' but country={country!r} " - f"(expected '{folder}/')" - ) - - for fname, rec in socs: - _check_required(fname, rec, SOC_REQUIRED, errors) - _check_source_urls(fname, rec, errors) - _check_slug(fname, rec.get("slug"), errors) - if "release_date" in rec: - _check_date(fname, rec["release_date"], errors) - _check_range(fname, "process_nm", rec.get("process_nm"), 1.0, 100.0, errors) - if rec.get("manufacturer") not in brand_slugs: - errors.append(f"{fname}: manufacturer '{rec.get('manufacturer')}' not a known brand") - - for fname, rec in phones: - _check_required(fname, rec, PHONE_REQUIRED, errors) - _check_source_urls(fname, rec, errors) - _check_slug(fname, rec.get("slug"), errors) - if "release_date" in rec: - _check_date(fname, rec["release_date"], errors) - _check_range(fname, "ram_gb", rec.get("ram_gb"), 0.016, 64, errors) - _check_identity_fields(fname, rec, errors) - _check_range(fname, "battery_mah", rec.get("battery_mah"), 500, 12000, errors) - _check_range(fname, "weight_g", rec.get("weight_g"), 50, 1500, errors) - if "msrp_usd" in rec: - _check_range(fname, "msrp_usd", rec["msrp_usd"], 50, 5000, errors) - if rec.get("brand") not in brand_slugs: - errors.append(f"{fname}: brand '{rec.get('brand')}' not a known brand") - if rec.get("soc") not in soc_slugs: - errors.append(f"{fname}: soc '{rec.get('soc')}' not a known SoC") - _check_variant_path(fname, rec, "smartphone", errors, allow_flat=True) - - for category, records in (("tablet", tablets), ("watch", watches), ("pda", pdas)): - for fname, rec in records: - _check_required(fname, rec, MOBILE_DEVICE_REQUIRED, errors) - _check_source_urls(fname, rec, errors) - _check_slug(fname, rec.get("slug"), errors) - if "release_date" in rec: - _check_date(fname, rec["release_date"], errors) - _check_range(fname, "ram_gb", rec.get("ram_gb"), 0.016, 64, errors) - _check_identity_fields(fname, rec, errors) - _check_range(fname, "battery_mah", rec.get("battery_mah"), 50, 20000, errors) - _check_range(fname, "weight_g", rec.get("weight_g"), 10, 2000, errors) - if "msrp_usd" in rec: - _check_range(fname, "msrp_usd", rec["msrp_usd"], 10, 10000, errors) - if rec.get("brand") not in brand_slugs: - errors.append(f"{fname}: brand '{rec.get('brand')}' not a known brand") - if rec.get("soc") is not None and rec.get("soc") not in soc_slugs: - errors.append(f"{fname}: soc '{rec.get('soc')}' not a known SoC") - _check_storage_options_gb(fname, rec, errors) - _check_variant_path(fname, rec, category, errors) - - for fname, rec in gpus: - _check_required(fname, rec, GPU_REQUIRED, errors) - _check_source_urls(fname, rec, errors) - _check_slug(fname, rec.get("slug"), errors) - if "release_date" in rec: - _check_date(fname, rec["release_date"], errors) - _check_range(fname, "memory_gb", rec.get("memory_gb"), 0.001, 512, errors) - _check_range(fname, "tdp_w", rec.get("tdp_w"), 1, 3000, errors) - if "msrp_usd" in rec: - _check_range(fname, "msrp_usd", rec["msrp_usd"], 50, 100000, errors) - if rec.get("manufacturer") not in brand_slugs: - errors.append(f"{fname}: manufacturer '{rec.get('manufacturer')}' not a known brand") - - valid_segments = {"desktop", "laptop", "hedt", "server"} - for fname, rec in cpus: - _check_required(fname, rec, CPU_REQUIRED, errors) - _check_source_urls(fname, rec, errors) - _check_slug(fname, rec.get("slug"), errors) - if "release_date" in rec: - _check_date(fname, rec["release_date"], errors) - _check_range(fname, "cores", rec.get("cores"), 1, 512, errors) - _check_range(fname, "threads", rec.get("threads"), 1, 1024, errors) - if "msrp_usd" in rec: - _check_range(fname, "msrp_usd", rec["msrp_usd"], 20, 50000, errors) - if rec.get("segment") not in valid_segments: - seg = rec.get("segment") - errors.append(f"{fname}: segment '{seg}' not in {sorted(valid_segments)}") - if rec.get("manufacturer") not in brand_slugs: - errors.append(f"{fname}: manufacturer '{rec.get('manufacturer')}' not a known brand") - - for fname, rec in laptops: - _check_required(fname, rec, LAPTOP_REQUIRED, errors) - _check_source_urls(fname, rec, errors) - _check_slug(fname, rec.get("slug"), errors) - if "release_date" in rec: - _check_date(fname, rec["release_date"], errors) - _check_range(fname, "ram_gb", rec.get("ram_gb"), 1, 256, errors) - if rec.get("storage_gb") is not None: - _check_range(fname, "storage_gb", rec.get("storage_gb"), 1, 65536, errors) - if rec.get("weight_g") is not None: - _check_range(fname, "weight_g", rec.get("weight_g"), 300, 6000, errors) - if "msrp_usd" in rec: - _check_range(fname, "msrp_usd", rec["msrp_usd"], 50, 50000, errors) - if rec.get("brand") not in brand_slugs: - errors.append(f"{fname}: brand '{rec.get('brand')}' not a known brand") - if rec.get("cpu") is not None and rec.get("cpu") not in cpu_slugs: - errors.append(f"{fname}: cpu '{rec.get('cpu')}' not a known CPU") - if rec.get("gpu") is not None and rec.get("gpu") not in gpu_slugs: - errors.append(f"{fname}: gpu '{rec.get('gpu')}' not a known GPU") - _check_variant_path(fname, rec, "laptop", errors, allow_flat=True) - - for fname, rec in monitors: - _check_required(fname, rec, MONITOR_REQUIRED, errors) - _check_source_urls(fname, rec, errors) - _check_slug(fname, rec.get("slug"), errors) - if "release_date" in rec: - _check_date(fname, rec["release_date"], errors) - _check_range(fname, "size_inch", rec.get("size_inch"), 5, 120, errors) - _check_range(fname, "refresh_hz", rec.get("refresh_hz"), 24, 1000, errors) - if rec.get("ppi") is not None: - _check_range(fname, "ppi", rec.get("ppi"), 20, 1000, errors) - if rec.get("rating") is not None: - _check_range(fname, "rating", rec.get("rating"), 0, 5, errors) - if "msrp_usd" in rec: - _check_range(fname, "msrp_usd", rec["msrp_usd"], 10, 50000, errors) - if rec.get("brand") not in brand_slugs: - errors.append(f"{fname}: brand '{rec.get('brand')}' not a known brand") - _check_variant_path(fname, rec, "monitor", errors, allow_flat=True) - - for fname, rec in software: - _check_required(fname, rec, SOFTWARE_REQUIRED, errors) - _check_source_urls(fname, rec, errors) - _check_slug(fname, rec.get("slug"), errors) - if rec.get("release_date") is not None: - _check_date(fname, rec["release_date"], errors) - - for fname, rec in websites: - _check_required(fname, rec, WEBSITE_REQUIRED, errors) - _check_source_urls(fname, rec, errors) - _check_slug(fname, rec.get("slug"), errors) - if rec.get("launch_date") is not None: - _check_date(fname, rec["launch_date"], errors) - - for fname, rec in catalog: - _check_required(fname, rec, DEVICE_CATALOG_REQUIRED, errors) - _check_source_urls(fname, rec, errors) - _check_slug(fname, rec.get("slug"), errors) - if rec.get("base_model_slug") is not None: - _check_slug(fname, rec["base_model_slug"], errors) - if "verified" in rec and not isinstance(rec["verified"], bool): - errors.append(f"{fname}: verified must be a boolean") - for field in ("model_numbers", "codenames", "marketing_names"): - _check_string_list(fname, rec, field, errors) - for field in ("form_factor", "device_type_guess"): - value = rec.get(field) - if value is not None and value not in FORM_FACTORS: - errors.append(f"{fname}: {field} '{value}' not in {sorted(FORM_FACTORS)}") - for field, lo, hi in ( - ("android_sdk_min", 1, 99), - ("android_sdk_max", 1, 99), - ("screen_density_dpi", 50, 1500), - ("release_year", 1990, 2100), - ): - value = rec.get(field) - if value is not None and (isinstance(value, bool) or not isinstance(value, int)): - errors.append(f"{fname}: {field} must be an integer") - else: - _check_range(fname, field, value, lo, hi, errors) - sdk_min, sdk_max = rec.get("android_sdk_min"), rec.get("android_sdk_max") - if isinstance(sdk_min, int) and isinstance(sdk_max, int) and sdk_max < sdk_min: - errors.append(f"{fname}: android_sdk_max={sdk_max} < android_sdk_min={sdk_min}") - if isinstance(rec.get("ram_gb"), bool): - errors.append(f"{fname}: ram_gb must be a number") - else: - _check_range(fname, "ram_gb", rec.get("ram_gb"), 0.016, 64, errors) - for field in ("soc_raw", "gpu_raw"): - value = rec.get(field) - if value is not None and not (isinstance(value, str) and value.strip()): - errors.append(f"{fname}: {field} must be a non-empty string") - resolution = rec.get("screen_resolution") - if resolution is not None and not ( - isinstance(resolution, str) and RESOLUTION_RE.match(resolution) - ): - errors.append(f"{fname}: screen_resolution '{resolution}' must look like '1080x2400'") - year_source = rec.get("release_year_source") - if year_source is not None and year_source not in RELEASE_YEAR_SOURCES: - errors.append( - f"{fname}: release_year_source '{year_source}' " - f"not in {sorted(RELEASE_YEAR_SOURCES)}" - ) - if (rec.get("release_year") is None) != (year_source is None): - errors.append(f"{fname}: release_year and release_year_source must be set together") - if rec.get("soc") is not None and rec.get("soc") not in soc_slugs: - errors.append(f"{fname}: soc '{rec.get('soc')}' not a known SoC") - promoted = rec.get("promoted_to") - if promoted is not None: - target, _, target_slug = str(promoted).partition("/") - if target not in PROMOTION_TARGETS or not SLUG_RE.match(target_slug): - errors.append( - f"{fname}: promoted_to '{promoted}' must be '/' " - f"with category in {sorted(PROMOTION_TARGETS)}" - ) - if rec.get("brand") not in brand_slugs: - errors.append(f"{fname}: brand '{rec.get('brand')}' not a known brand") - # No year folder: a catalog entry's release date is unknown by definition. - parts = Path(fname).parts - if len(parts) != 3: - errors.append(f"{fname}: device_catalog entries must live at " - "'device_catalog//.json'") - else: - if parts[1] != rec.get("brand"): - errors.append( - f"{fname}: lives in brand '{parts[1]}' but brand='{rec.get('brand')}'" - ) - if parts[2] != f"{rec.get('slug')}.json": - errors.append(f"{fname}: filename must match slug '{rec.get('slug')}'") - - return errors - - -def run() -> int: - try: - sys.stdout.reconfigure(encoding="utf-8") # type: ignore[union-attr] - except Exception: - pass - errors = validate() - if errors: - print(f"❌ Data validation failed ({len(errors)} issue(s)):") - for err in errors: - print(f" - {err}") - return 1 - print("✅ Data validation passed") - return 0 - - -if __name__ == "__main__": - sys.exit(run()) +"""Validate the records under ``data/`` with the standard library only. + +Checks: required fields, the slug convention, value ranges and units, the +folder a record lives in, and that references by slug (brand, soc, cpu, gpu) +resolve. Run with ``python -m app.validate``; it prints every issue and exits +non-zero if there is any. ``pr-checks.yml`` runs it on every PR and +``.github/scripts/build-api.sh`` runs it before the API is generated. + +Section numbers in the messages (§14.1, §14.2) are those of +``docs/SPEC.md`` in TechEngine, whose ``app/validate.py`` is the full +validator. +""" + +from __future__ import annotations + +import json +import re +import sys +from collections.abc import Iterator +from pathlib import Path +from typing import Any + +DATA_DIR = Path(__file__).resolve().parent.parent / "data" + +SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") + +# country is optional: many device makers have no sourced country, and it is +# never guessed. A brand without one lives under brand/unknown/. +BRAND_REQUIRED = {"slug", "name", "categories", "source_urls"} +BRAND_UNKNOWN_COUNTRY_DIR = "unknown" +BRAND_CATEGORIES = { + "smartphone-oem", + "soc-designer", + "cpu-designer", + "gpu-designer", + "ip-licensor", + "aib-partner", + "pc-oem", + "chipset-maker", + "sub-brand", + "defunct", +} +COUNTRY_RE = re.compile(r"^[A-Z]{2}$") +# Media links may only point at free Wikimedia Commons files, never third-party hotlinks. +COMMONS_UPLOAD_RE = re.compile(r"https://upload\.wikimedia\.org/wikipedia/commons/\S+") +COMMONS_FILEPATH_RE = re.compile(r"https://commons\.wikimedia\.org/wiki/Special:FilePath/\S+") +SOC_REQUIRED = {"slug", "name", "manufacturer", "release_date", "process_nm", "gpu_name"} +PHONE_REQUIRED = { + "slug", + "name", + "brand", + "soc", + "release_date", + "ram_gb", + "os", +} + +MOBILE_DEVICE_REQUIRED = { + "slug", + "name", + "brand", + "release_date", + "ram_gb", + "os", + "source_urls", + "verified", +} + +GPU_REQUIRED = { + "slug", + "name", + "manufacturer", + "architecture", + "release_date", + "memory_gb", + "memory_type", + "memory_bus_bit", + "base_clock_mhz", + "boost_clock_mhz", + "tdp_w", + "pcie_version", +} + +CPU_REQUIRED = { + "slug", + "name", + "manufacturer", + "release_date", + "segment", + "architecture", + "cores", + "threads", +} + +LAPTOP_REQUIRED = { + "slug", + "name", + "brand", + "release_date", + "ram_gb", + "os", + "source_urls", + "verified", +} + +MONITOR_REQUIRED = { + "slug", + "name", + "brand", + "release_date", + "size_inch", + "resolution", + "source_urls", + "verified", +} + +SOFTWARE_REQUIRED = { + "slug", + "name", + "source_urls", + "verified", +} + +WEBSITE_REQUIRED = { + "slug", + "name", + "source_urls", + "verified", +} + +DEVICE_CATALOG_REQUIRED = { + "slug", + "name", + "brand", + "source_urls", + "verified", +} +FORM_FACTORS = {"phone", "tablet", "watch", "tv", "other"} +# Categories a catalog entry can be promoted to (``promoted_to`` = "/"). +PROMOTION_TARGETS = {"smartphone", "tablet", "watch", "pda"} +DATE_PRECISIONS = {"day", "month", "year", "year_estimated"} +RELEASE_YEAR_SOURCES = {"model_code", "record"} +RESOLUTION_RE = re.compile(r"^[1-9]\d{1,4}x[1-9]\d{1,4}$") + +DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") + + +def _load(subdir: str) -> list[tuple[str, dict[str, Any]]]: + path = DATA_DIR / subdir + if not path.exists(): + return [] + return [ + (str(f.relative_to(DATA_DIR)), json.loads(f.read_text(encoding="utf-8-sig"))) + for f in sorted(path.rglob("*.json")) # recurse into brand subfolders + ] + + +def _check_required( + name: str, record: dict[str, Any], required: set[str], errors: list[str] +) -> None: + missing = required - record.keys() + if missing: + errors.append(f"{name}: missing required fields {sorted(missing)}") + + +def _check_slug(name: str, slug: object, errors: list[str]) -> None: + if not isinstance(slug, str) or not SLUG_RE.match(slug): + errors.append(f"{name}: invalid slug '{slug}' (must be kebab-case, §14.1)") + + +def _check_range( + name: str, field: str, value: object, lo: float, hi: float, errors: list[str] +) -> None: + if value is None: + return + if not isinstance(value, (int, float)) or not (lo <= value <= hi): + errors.append(f"{name}: {field}={value} out of range [{lo}, {hi}]") + + +def _check_date(name: str, value: object, errors: list[str]) -> None: + if not isinstance(value, str) or not DATE_RE.match(value): + errors.append(f"{name}: release_date '{value}' must be ISO 8601 YYYY-MM-DD (§14.2)") + + +def _check_string_list( + name: str, record: dict[str, Any], field: str, errors: list[str] +) -> None: + """Optional list field: unique, non-empty strings (absent = []).""" + if field not in record: + return + value = record[field] + if not isinstance(value, list) or not all( + isinstance(item, str) and item.strip() for item in value + ): + errors.append(f"{name}: {field} must be a list of non-empty strings") + elif len(set(value)) != len(value): + errors.append(f"{name}: {field} contains duplicates") + + +def _check_identity_fields(name: str, record: dict[str, Any], errors: list[str]) -> None: + """model_numbers / codenames / release_date_precision on device records.""" + _check_string_list(name, record, "model_numbers", errors) + _check_string_list(name, record, "codenames", errors) + precision = record.get("release_date_precision") + if precision is not None and precision not in DATE_PRECISIONS: + errors.append( + f"{name}: release_date_precision '{precision}' not in {sorted(DATE_PRECISIONS)}" + ) + + +def _check_unique_slugs( + category: str, records: list[tuple[str, dict[str, Any]]], errors: list[str] +) -> None: + """Each category's `slug` must be unique — seed/dump load into a UNIQUE column.""" + seen: dict[str, str] = {} + for fname, rec in records: + slug = rec.get("slug") + if not isinstance(slug, str): + continue + if slug in seen: + errors.append( + f"{fname}: duplicate {category} slug '{slug}' (also in {seen[slug]})" + ) + else: + seen[slug] = fname + + +def _check_source_urls(name: str, record: dict[str, Any], errors: list[str]) -> None: + urls = record.get("source_urls") + if not isinstance(urls, list) or not urls or not all( + isinstance(url, str) and url.startswith(("http://", "https://")) for url in urls + ): + errors.append(f"{name}: source_urls must be a non-empty list of http(s) URL strings") + + +def _check_storage_options_gb(name: str, record: dict[str, Any], errors: list[str]) -> None: + values = record.get("storage_options_gb") + if values is None: + return + if not isinstance(values, list): + errors.append(f"{name}: storage_options_gb must be a list of integer GB values") + return + bad = [value for value in values if not isinstance(value, int) or value < 1] + if bad: + errors.append(f"{name}: storage_options_gb contains invalid integer GB values {bad}") + + +def _image_urls(value: Any) -> Iterator[Any]: + """Every ``image_url`` at any depth; ``raw_merged_records`` keep merged-in copies.""" + if isinstance(value, dict): + for key, item in value.items(): + if key == "image_url": + yield item + else: + yield from _image_urls(item) + elif isinstance(value, list): + for item in value: + yield from _image_urls(item) + + +def _check_media(name: str, record: dict[str, Any], errors: list[str]) -> None: + """Photos and logos may only link Wikimedia Commons files; photos carry their credit.""" + if any( + url is not None and not (isinstance(url, str) and COMMONS_UPLOAD_RE.fullmatch(url)) + for url in _image_urls(record) + ): + errors.append( + f"{name}: image_url must be an upload.wikimedia.org/wikipedia/commons file or null" + ) + elif record.get("image_url") is not None and not ( + record.get("image_license") and record.get("image_attribution") + ): + errors.append(f"{name}: image_url needs image_license and image_attribution") + logo = record.get("logo_url") + if logo is not None and not ( + isinstance(logo, str) + and (COMMONS_UPLOAD_RE.fullmatch(logo) or COMMONS_FILEPATH_RE.fullmatch(logo)) + ): + errors.append(f"{name}: logo_url must be a Wikimedia Commons file or null") + + +def _check_variant_path( + fname: str, + rec: dict[str, Any], + category: str, + errors: list[str], + *, + allow_flat: bool = False, +) -> None: + parts = Path(fname).parts + if allow_flat and len(parts) == 4: + return + if len(parts) != 5: + errors.append( + f"{fname}: {category} variants must live at " + f"'{category}////.json'" + ) + return + _, brand, year, base_model_slug, filename = parts + if rec.get("brand") != brand: + errors.append(f"{fname}: lives in brand '{brand}' but brand='{rec.get('brand')}'") + release_year = str(rec.get("release_date", ""))[:4] + if release_year and year != release_year: + errors.append( + f"{fname}: lives in year '{year}' but release_date starts with '{release_year}'" + ) + if rec.get("base_model_slug") and rec.get("base_model_slug") != base_model_slug: + errors.append( + f"{fname}: lives under base '{base_model_slug}' but " + f"base_model_slug='{rec.get('base_model_slug')}'" + ) + if filename != f"{rec.get('slug')}.json": + errors.append(f"{fname}: filename must match slug '{rec.get('slug')}'") + + +def validate() -> list[str]: + errors: list[str] = [] + + brands = _load("brand") + socs = _load("soc") + phones = _load("smartphone") + tablets = _load("tablet") + watches = _load("watch") + pdas = _load("pda") + gpus = _load("gpu") + cpus = _load("cpu") + laptops = _load("laptop") + monitors = _load("monitor") + software = _load("software") + websites = _load("website") + catalog = _load("device_catalog") + + brand_slugs = {rec["slug"] for _, rec in brands if "slug" in rec} + soc_slugs = {rec["slug"] for _, rec in socs if "slug" in rec} + cpu_slugs = {rec["slug"] for _, rec in cpus if "slug" in rec} + gpu_slugs = {rec["slug"] for _, rec in gpus if "slug" in rec} + + for category, records in ( + ("brand", brands), + ("soc", socs), + ("smartphone", phones), + ("tablet", tablets), + ("watch", watches), + ("pda", pdas), + ("gpu", gpus), + ("cpu", cpus), + ("laptop", laptops), + ("monitor", monitors), + ("software", software), + ("website", websites), + ("device_catalog", catalog), + ): + _check_unique_slugs(category, records, errors) + for fname, rec in records: + _check_media(fname, rec, errors) + + for fname, rec in brands: + _check_required(fname, rec, BRAND_REQUIRED, errors) + _check_source_urls(fname, rec, errors) + _check_slug(fname, rec.get("slug"), errors) + if "founded_year" in rec: + _check_range(fname, "founded_year", rec["founded_year"], 1800, 2100, errors) + country = rec.get("country") + if country is not None and not (isinstance(country, str) and COUNTRY_RE.match(country)): + errors.append(f"{fname}: country '{country}' must be ISO 3166 alpha-2 (e.g. 'KR')") + cats = rec.get("categories") + if not isinstance(cats, list) or not cats: + errors.append(f"{fname}: categories must be a non-empty list") + else: + bad = [c for c in cats if c not in BRAND_CATEGORIES] + if bad: + errors.append( + f"{fname}: invalid categories {bad}; allowed = {sorted(BRAND_CATEGORIES)}" + ) + if len(set(cats)) != len(cats): + errors.append(f"{fname}: categories contains duplicates") + # Path convention: brand//.json + parts = Path(fname).parts + folder = country.lower() if isinstance(country, str) else BRAND_UNKNOWN_COUNTRY_DIR + if len(parts) != 3: + errors.append( + f"{fname}: must live at 'brand//.json' " + f"(got {len(parts) - 1} subpath components)" + ) + elif parts[1] != folder: + errors.append( + f"{fname}: lives in '{parts[1]}/' but country={country!r} " + f"(expected '{folder}/')" + ) + + for fname, rec in socs: + _check_required(fname, rec, SOC_REQUIRED, errors) + _check_source_urls(fname, rec, errors) + _check_slug(fname, rec.get("slug"), errors) + if "release_date" in rec: + _check_date(fname, rec["release_date"], errors) + _check_range(fname, "process_nm", rec.get("process_nm"), 1.0, 100.0, errors) + if rec.get("manufacturer") not in brand_slugs: + errors.append(f"{fname}: manufacturer '{rec.get('manufacturer')}' not a known brand") + + for fname, rec in phones: + _check_required(fname, rec, PHONE_REQUIRED, errors) + _check_source_urls(fname, rec, errors) + _check_slug(fname, rec.get("slug"), errors) + if "release_date" in rec: + _check_date(fname, rec["release_date"], errors) + _check_range(fname, "ram_gb", rec.get("ram_gb"), 0.016, 64, errors) + _check_identity_fields(fname, rec, errors) + _check_range(fname, "battery_mah", rec.get("battery_mah"), 500, 12000, errors) + _check_range(fname, "weight_g", rec.get("weight_g"), 50, 1500, errors) + if "msrp_usd" in rec: + _check_range(fname, "msrp_usd", rec["msrp_usd"], 50, 5000, errors) + if rec.get("brand") not in brand_slugs: + errors.append(f"{fname}: brand '{rec.get('brand')}' not a known brand") + if rec.get("soc") not in soc_slugs: + errors.append(f"{fname}: soc '{rec.get('soc')}' not a known SoC") + _check_variant_path(fname, rec, "smartphone", errors, allow_flat=True) + + for category, records in (("tablet", tablets), ("watch", watches), ("pda", pdas)): + for fname, rec in records: + _check_required(fname, rec, MOBILE_DEVICE_REQUIRED, errors) + _check_source_urls(fname, rec, errors) + _check_slug(fname, rec.get("slug"), errors) + if "release_date" in rec: + _check_date(fname, rec["release_date"], errors) + _check_range(fname, "ram_gb", rec.get("ram_gb"), 0.016, 64, errors) + _check_identity_fields(fname, rec, errors) + _check_range(fname, "battery_mah", rec.get("battery_mah"), 50, 20000, errors) + _check_range(fname, "weight_g", rec.get("weight_g"), 10, 2000, errors) + if "msrp_usd" in rec: + _check_range(fname, "msrp_usd", rec["msrp_usd"], 10, 10000, errors) + if rec.get("brand") not in brand_slugs: + errors.append(f"{fname}: brand '{rec.get('brand')}' not a known brand") + if rec.get("soc") is not None and rec.get("soc") not in soc_slugs: + errors.append(f"{fname}: soc '{rec.get('soc')}' not a known SoC") + _check_storage_options_gb(fname, rec, errors) + _check_variant_path(fname, rec, category, errors) + + for fname, rec in gpus: + _check_required(fname, rec, GPU_REQUIRED, errors) + _check_source_urls(fname, rec, errors) + _check_slug(fname, rec.get("slug"), errors) + if "release_date" in rec: + _check_date(fname, rec["release_date"], errors) + _check_range(fname, "memory_gb", rec.get("memory_gb"), 0.001, 512, errors) + _check_range(fname, "tdp_w", rec.get("tdp_w"), 1, 3000, errors) + if "msrp_usd" in rec: + _check_range(fname, "msrp_usd", rec["msrp_usd"], 50, 100000, errors) + if rec.get("manufacturer") not in brand_slugs: + errors.append(f"{fname}: manufacturer '{rec.get('manufacturer')}' not a known brand") + + valid_segments = {"desktop", "laptop", "hedt", "server"} + for fname, rec in cpus: + _check_required(fname, rec, CPU_REQUIRED, errors) + _check_source_urls(fname, rec, errors) + _check_slug(fname, rec.get("slug"), errors) + if "release_date" in rec: + _check_date(fname, rec["release_date"], errors) + _check_range(fname, "cores", rec.get("cores"), 1, 512, errors) + _check_range(fname, "threads", rec.get("threads"), 1, 1024, errors) + if "msrp_usd" in rec: + _check_range(fname, "msrp_usd", rec["msrp_usd"], 20, 50000, errors) + if rec.get("segment") not in valid_segments: + seg = rec.get("segment") + errors.append(f"{fname}: segment '{seg}' not in {sorted(valid_segments)}") + if rec.get("manufacturer") not in brand_slugs: + errors.append(f"{fname}: manufacturer '{rec.get('manufacturer')}' not a known brand") + + for fname, rec in laptops: + _check_required(fname, rec, LAPTOP_REQUIRED, errors) + _check_source_urls(fname, rec, errors) + _check_slug(fname, rec.get("slug"), errors) + if "release_date" in rec: + _check_date(fname, rec["release_date"], errors) + _check_range(fname, "ram_gb", rec.get("ram_gb"), 1, 256, errors) + if rec.get("storage_gb") is not None: + _check_range(fname, "storage_gb", rec.get("storage_gb"), 1, 65536, errors) + if rec.get("weight_g") is not None: + _check_range(fname, "weight_g", rec.get("weight_g"), 300, 6000, errors) + if "msrp_usd" in rec: + _check_range(fname, "msrp_usd", rec["msrp_usd"], 50, 50000, errors) + if rec.get("brand") not in brand_slugs: + errors.append(f"{fname}: brand '{rec.get('brand')}' not a known brand") + if rec.get("cpu") is not None and rec.get("cpu") not in cpu_slugs: + errors.append(f"{fname}: cpu '{rec.get('cpu')}' not a known CPU") + if rec.get("gpu") is not None and rec.get("gpu") not in gpu_slugs: + errors.append(f"{fname}: gpu '{rec.get('gpu')}' not a known GPU") + _check_variant_path(fname, rec, "laptop", errors, allow_flat=True) + + for fname, rec in monitors: + _check_required(fname, rec, MONITOR_REQUIRED, errors) + _check_source_urls(fname, rec, errors) + _check_slug(fname, rec.get("slug"), errors) + if "release_date" in rec: + _check_date(fname, rec["release_date"], errors) + _check_range(fname, "size_inch", rec.get("size_inch"), 5, 120, errors) + _check_range(fname, "refresh_hz", rec.get("refresh_hz"), 24, 1000, errors) + if rec.get("ppi") is not None: + _check_range(fname, "ppi", rec.get("ppi"), 20, 1000, errors) + if rec.get("rating") is not None: + _check_range(fname, "rating", rec.get("rating"), 0, 5, errors) + if "msrp_usd" in rec: + _check_range(fname, "msrp_usd", rec["msrp_usd"], 10, 50000, errors) + if rec.get("brand") not in brand_slugs: + errors.append(f"{fname}: brand '{rec.get('brand')}' not a known brand") + _check_variant_path(fname, rec, "monitor", errors, allow_flat=True) + + for fname, rec in software: + _check_required(fname, rec, SOFTWARE_REQUIRED, errors) + _check_source_urls(fname, rec, errors) + _check_slug(fname, rec.get("slug"), errors) + if rec.get("release_date") is not None: + _check_date(fname, rec["release_date"], errors) + + for fname, rec in websites: + _check_required(fname, rec, WEBSITE_REQUIRED, errors) + _check_source_urls(fname, rec, errors) + _check_slug(fname, rec.get("slug"), errors) + if rec.get("launch_date") is not None: + _check_date(fname, rec["launch_date"], errors) + + for fname, rec in catalog: + _check_required(fname, rec, DEVICE_CATALOG_REQUIRED, errors) + _check_source_urls(fname, rec, errors) + _check_slug(fname, rec.get("slug"), errors) + if rec.get("base_model_slug") is not None: + _check_slug(fname, rec["base_model_slug"], errors) + if "verified" in rec and not isinstance(rec["verified"], bool): + errors.append(f"{fname}: verified must be a boolean") + for field in ("model_numbers", "codenames", "marketing_names"): + _check_string_list(fname, rec, field, errors) + for field in ("form_factor", "device_type_guess"): + value = rec.get(field) + if value is not None and value not in FORM_FACTORS: + errors.append(f"{fname}: {field} '{value}' not in {sorted(FORM_FACTORS)}") + for field, lo, hi in ( + ("android_sdk_min", 1, 99), + ("android_sdk_max", 1, 99), + ("screen_density_dpi", 50, 1500), + ("release_year", 1990, 2100), + ): + value = rec.get(field) + if value is not None and (isinstance(value, bool) or not isinstance(value, int)): + errors.append(f"{fname}: {field} must be an integer") + else: + _check_range(fname, field, value, lo, hi, errors) + sdk_min, sdk_max = rec.get("android_sdk_min"), rec.get("android_sdk_max") + if isinstance(sdk_min, int) and isinstance(sdk_max, int) and sdk_max < sdk_min: + errors.append(f"{fname}: android_sdk_max={sdk_max} < android_sdk_min={sdk_min}") + if isinstance(rec.get("ram_gb"), bool): + errors.append(f"{fname}: ram_gb must be a number") + else: + _check_range(fname, "ram_gb", rec.get("ram_gb"), 0.016, 64, errors) + for field in ("soc_raw", "gpu_raw"): + value = rec.get(field) + if value is not None and not (isinstance(value, str) and value.strip()): + errors.append(f"{fname}: {field} must be a non-empty string") + resolution = rec.get("screen_resolution") + if resolution is not None and not ( + isinstance(resolution, str) and RESOLUTION_RE.match(resolution) + ): + errors.append(f"{fname}: screen_resolution '{resolution}' must look like '1080x2400'") + year_source = rec.get("release_year_source") + if year_source is not None and year_source not in RELEASE_YEAR_SOURCES: + errors.append( + f"{fname}: release_year_source '{year_source}' " + f"not in {sorted(RELEASE_YEAR_SOURCES)}" + ) + if (rec.get("release_year") is None) != (year_source is None): + errors.append(f"{fname}: release_year and release_year_source must be set together") + if rec.get("soc") is not None and rec.get("soc") not in soc_slugs: + errors.append(f"{fname}: soc '{rec.get('soc')}' not a known SoC") + promoted = rec.get("promoted_to") + if promoted is not None: + target, _, target_slug = str(promoted).partition("/") + if target not in PROMOTION_TARGETS or not SLUG_RE.match(target_slug): + errors.append( + f"{fname}: promoted_to '{promoted}' must be '/' " + f"with category in {sorted(PROMOTION_TARGETS)}" + ) + if rec.get("brand") not in brand_slugs: + errors.append(f"{fname}: brand '{rec.get('brand')}' not a known brand") + # No year folder: a catalog entry's release date is unknown by definition. + parts = Path(fname).parts + if len(parts) != 3: + errors.append(f"{fname}: device_catalog entries must live at " + "'device_catalog//.json'") + else: + if parts[1] != rec.get("brand"): + errors.append( + f"{fname}: lives in brand '{parts[1]}' but brand='{rec.get('brand')}'" + ) + if parts[2] != f"{rec.get('slug')}.json": + errors.append(f"{fname}: filename must match slug '{rec.get('slug')}'") + + return errors + + +def run() -> int: + try: + sys.stdout.reconfigure(encoding="utf-8") # type: ignore[union-attr] + except Exception: + pass + errors = validate() + if errors: + print(f"❌ Data validation failed ({len(errors)} issue(s)):") + for err in errors: + print(f" - {err}") + return 1 + print("✅ Data validation passed") + return 0 + + +if __name__ == "__main__": + sys.exit(run()) From c01f84735dbdba73d8fdf64893b2df853f501776 Mon Sep 17 00:00:00 2001 From: Seungpyo1007 Date: Thu, 12 Nov 2026 02:06:01 +0900 Subject: [PATCH 8/8] chore: drop a local tooling entry from .gitignore The directory it named is local to one machine and is kept out through the clone's own exclude file instead. --- .gitignore | 3 --- 1 file changed, 3 deletions(-) diff --git a/.gitignore b/.gitignore index df91f0027f54..0279ca0af231 100644 --- a/.gitignore +++ b/.gitignore @@ -26,9 +26,6 @@ __pycache__/ # Local-only handoff notes (private; never committed to the public repo) *.local.md -# Local tooling/editor config (kept out of the public repo) -.claude/ - # The static API is generated on every Pages deploy (.github/scripts/build-api.sh, # then site/scripts/build-*.mjs). Only the hand-made games pointer is tracked. /site/public/v1/*