diff --git a/Justfile b/Justfile index 53a5941..ea1194f 100644 --- a/Justfile +++ b/Justfile @@ -215,7 +215,7 @@ repl: @just trapos --cli # Local CI entry point used by Git hooks. Pass args through to `test`. -ci *args: check-craftos check +ci *args: check-craftos check check-packages @just test {{args}} @just test-timeout @@ -368,6 +368,93 @@ check: check-luacheck check-lychee luacheck --quiet . @just lint-markdown +# Validate package descriptors and require version bumps for changed package files. +check-packages: check-jq + #!/usr/bin/env bash + set -euo pipefail + repo='{{justfile_directory()}}' + cd "$repo" + + semver_gt() { + local a_major a_minor a_patch b_major b_minor b_patch + IFS=. read -r a_major a_minor a_patch <<<"$1" + IFS=. read -r b_major b_minor b_patch <<<"$2" + if [ "${a_major:-0}" -gt "${b_major:-0}" ]; then return 0; fi + if [ "${a_major:-0}" -lt "${b_major:-0}" ]; then return 1; fi + if [ "${a_minor:-0}" -gt "${b_minor:-0}" ]; then return 0; fi + if [ "${a_minor:-0}" -lt "${b_minor:-0}" ]; then return 1; fi + [ "${a_patch:-0}" -gt "${b_patch:-0}" ] + } + + fail=0 + packages=() + while IFS= read -r name; do packages+=("$name"); done < <(jq -r '.packages | keys[]' packages/index.json | sort) + + for name in "${packages[@]}"; do + desc="packages/$name/ccpm.json" + if [ ! -f "$desc" ]; then + printf '%s\n' "FAIL: packages/index.json lists missing descriptor $desc" >&2 + fail=1 + continue + fi + + desc_name="$(jq -r '.name // empty' "$desc")" + desc_version="$(jq -r '.version // empty' "$desc")" + index_version="$(jq -r --arg name "$name" '.packages[$name] // empty' packages/index.json)" + if [ "$desc_name" != "$name" ]; then + printf '%s\n' "FAIL: $desc has name '$desc_name', expected '$name'" >&2 + fail=1 + fi + if [ "$desc_version" != "$index_version" ]; then + printf '%s\n' "FAIL: $name version differs between descriptor ($desc_version) and packages/index.json ($index_version)" >&2 + fail=1 + fi + + last_desc_commit="$(git log -n 1 --format=%H -- "$desc" || true)" + if [ -z "$last_desc_commit" ]; then + continue + fi + old_version="$(git show "$last_desc_commit:$desc" | jq -r '.version // empty')" + + files=() + while IFS= read -r file; do files+=("$file"); done < <(jq -r '.files[]?' "$desc") + if [ "${#files[@]}" -gt 0 ] && ! git diff --quiet "$last_desc_commit" -- "${files[@]}"; then + if ! semver_gt "$desc_version" "$old_version"; then + printf '%s\n' "FAIL: $name package files changed since $desc was last bumped ($old_version); bump $desc and packages/index.json" >&2 + git diff --name-only "$last_desc_commit" -- "${files[@]}" >&2 + fail=1 + fi + fi + done + + for desc in packages/*/ccpm.json; do + [ -e "$desc" ] || continue + name="$(jq -r '.name // empty' "$desc")" + if ! jq -e --arg name "$name" '.packages[$name] != null' packages/index.json >/dev/null; then + printf '%s\n' "FAIL: descriptor $desc is missing from packages/index.json" >&2 + fail=1 + fi + done + + trapos_desc='packages/trapos/ccpm.json' + trapos_commit="$(git log -n 1 --format=%H -- "$trapos_desc" || true)" + if [ -n "$trapos_commit" ]; then + trapos_version="$(jq -r '.version // empty' "$trapos_desc")" + old_trapos_version="$(git show "$trapos_commit:$trapos_desc" | jq -r '.version // empty')" + deps=() + while IFS= read -r dep; do deps+=("packages/$dep/ccpm.json"); done < <(jq -r '.dependencies[]?' "$trapos_desc") + if [ "${#deps[@]}" -gt 0 ] && ! git diff --quiet "$trapos_commit" -- "${deps[@]}"; then + if ! semver_gt "$trapos_version" "$old_trapos_version"; then + printf '%s\n' "FAIL: trapos dependencies changed since trapos was last bumped ($old_trapos_version); bump $trapos_desc and packages/index.json" >&2 + git diff --name-only "$trapos_commit" -- "${deps[@]}" >&2 + fail=1 + fi + fi + fi + + if [ "$fail" -ne 0 ]; then exit 1; fi + printf '%s\n' 'OK: package versions aligned' + # Validate local markdown links and heading anchors with lychee. lint-markdown: check-lychee lychee --config lychee.toml . diff --git a/manifest.json b/manifest.json index 662cc13..74bfeec 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { "name": "TrapOS", - "version": "0.4.0", + "version": "0.5.0", "branch": "next", "packages": [ "trapos" diff --git a/packages/index.json b/packages/index.json index f2ba10a..68e3e56 100644 --- a/packages/index.json +++ b/packages/index.json @@ -1,11 +1,11 @@ { "packages": { - "tos-core": "0.2.0", - "tos-test": "0.1.0", - "tos-boot": "0.1.0", - "tos-net": "0.1.0", - "tos-ui": "0.1.0", - "tos-ai": "0.1.0", - "trapos": "0.4.0" + "tos-core": "0.3.0", + "tos-test": "0.2.0", + "tos-boot": "0.2.0", + "tos-net": "0.2.0", + "tos-ui": "0.2.0", + "tos-ai": "0.2.0", + "trapos": "0.5.0" } } diff --git a/packages/tos-ai/ccpm.json b/packages/tos-ai/ccpm.json index 4520f11..b23cc35 100644 --- a/packages/tos-ai/ccpm.json +++ b/packages/tos-ai/ccpm.json @@ -1,6 +1,6 @@ { "name": "tos-ai", - "version": "0.1.0", + "version": "0.2.0", "description": "TrapOS AI hello-world client (opencode proxy)", "dependencies": ["tos-core"], "files": [ diff --git a/packages/tos-boot/ccpm.json b/packages/tos-boot/ccpm.json index 8d113e4..c5d5243 100644 --- a/packages/tos-boot/ccpm.json +++ b/packages/tos-boot/ccpm.json @@ -1,6 +1,6 @@ { "name": "tos-boot", - "version": "0.1.0", + "version": "0.2.0", "description": "TrapOS boot: startup MOTD and autostart server launcher", "dependencies": ["tos-core"], "files": [ diff --git a/packages/tos-core/ccpm.json b/packages/tos-core/ccpm.json index 51c7f19..ab6aa66 100644 --- a/packages/tos-core/ccpm.json +++ b/packages/tos-core/ccpm.json @@ -1,6 +1,6 @@ { "name": "tos-core", - "version": "0.2.0", + "version": "0.3.0", "description": "TrapOS base: package manager, event loop, upgrade and event tools", "dependencies": [], "files": [ diff --git a/packages/tos-net/ccpm.json b/packages/tos-net/ccpm.json index eff2311..a2af338 100644 --- a/packages/tos-net/ccpm.json +++ b/packages/tos-net/ccpm.json @@ -1,6 +1,6 @@ { "name": "tos-net", - "version": "0.1.0", + "version": "0.2.0", "description": "TrapOS networking: routed modem messaging, router, ping", "dependencies": ["tos-core"], "files": [ diff --git a/packages/tos-test/ccpm.json b/packages/tos-test/ccpm.json index e92df28..db385f2 100644 --- a/packages/tos-test/ccpm.json +++ b/packages/tos-test/ccpm.json @@ -1,6 +1,6 @@ { "name": "tos-test", - "version": "0.1.0", + "version": "0.2.0", "description": "TrapOS test framework and CraftOS-PC suite runner", "dependencies": ["tos-core"], "files": [ diff --git a/packages/tos-ui/ccpm.json b/packages/tos-ui/ccpm.json index bd1dd5d..179feb4 100644 --- a/packages/tos-ui/ccpm.json +++ b/packages/tos-ui/ccpm.json @@ -1,6 +1,6 @@ { "name": "tos-ui", - "version": "0.1.0", + "version": "0.2.0", "description": "TrapOS terminal UI toolkit and demo", "dependencies": ["tos-core"], "files": [ diff --git a/packages/trapos/ccpm.json b/packages/trapos/ccpm.json index 0ddbf5c..5c96e63 100644 --- a/packages/trapos/ccpm.json +++ b/packages/trapos/ccpm.json @@ -1,6 +1,6 @@ { "name": "trapos", - "version": "0.4.0", + "version": "0.5.0", "description": "TrapOS full install meta-package", "dependencies": ["tos-boot", "tos-net", "tos-ui", "tos-test"], "files": [], diff --git a/tests/packages.lua b/tests/packages.lua new file mode 100644 index 0000000..865a9f6 --- /dev/null +++ b/tests/packages.lua @@ -0,0 +1,101 @@ +local createLibTest = require('/apis/libtest'); + +local testlib = createLibTest({ ... }); + +local PACKAGES_DIR = '/trapos/packages'; +local INDEX_PATH = PACKAGES_DIR .. '/index.json'; + +local function readJson(path) + local file = fs.open(path, 'r'); + testlib.assertTrue(file, 'missing JSON file: ' .. path); + local body = file.readAll(); + file.close(); + local value = textutils.unserializeJSON(body); + testlib.assertTrue(value, 'invalid JSON file: ' .. path); + return value; +end + +local function sortedKeys(map) + local keys = {}; + for key in pairs(map or {}) do + keys[#keys + 1] = key; + end + table.sort(keys); + return keys; +end + +local function descriptorPaths() + local paths = {}; + for _, name in ipairs(fs.list(PACKAGES_DIR)) do + local dir = PACKAGES_DIR .. '/' .. name; + local path = dir .. '/ccpm.json'; + if fs.isDir(dir) and fs.exists(path) then + paths[#paths + 1] = { name = name, path = path }; + end + end + table.sort(paths, function(a, b) return a.name < b.name; end); + return paths; +end + +local function packageMap() + local packages = {}; + for _, entry in ipairs(descriptorPaths()) do + packages[entry.name] = readJson(entry.path); + end + return packages; +end + +local function assertSemver(version, label) + testlib.assertTrue(type(version) == 'string', label .. ' version must be a string'); + testlib.assertTrue(string.match(version, '^%d+%.%d+%.%d+$') ~= nil, label .. ' version must be x.y.z'); +end + +testlib.test('package index matches descriptors', function() + local index = readJson(INDEX_PATH); + local packages = packageMap(); + local indexNames = sortedKeys(index.packages); + local descriptorNames = sortedKeys(packages); + + testlib.assertEquals(#indexNames, #descriptorNames, 'index and descriptor counts differ'); + for i, name in ipairs(descriptorNames) do + testlib.assertEquals(indexNames[i], name, 'index package names differ'); + testlib.assertEquals(index.packages[name], packages[name].version, name .. ' index version differs'); + end +end); + +testlib.test('package descriptors are internally consistent', function() + local packages = packageMap(); + for name, desc in pairs(packages) do + testlib.assertEquals(desc.name, name, name .. ' descriptor name differs from directory'); + assertSemver(desc.version, name); + testlib.assertTrue(type(desc.dependencies) == 'table', name .. ' dependencies must be a table'); + testlib.assertTrue(type(desc.files) == 'table', name .. ' files must be a table'); + testlib.assertTrue(type(desc.autostart) == 'table', name .. ' autostart must be a table'); + + if name ~= 'trapos' then + testlib.assertTrue(#desc.files > 0, name .. ' must ship at least one file'); + end + + for _, dependency in ipairs(desc.dependencies) do + testlib.assertTrue(packages[dependency] ~= nil, name .. ' depends on unknown package ' .. dependency); + end + + local files = {}; + for _, filePath in ipairs(desc.files) do + files[filePath] = true; + testlib.assertTrue(fs.exists('/trapos/' .. filePath), name .. ' missing shipped file ' .. filePath); + end + + for _, autostart in ipairs(desc.autostart) do + testlib.assertTrue(files[autostart .. '.lua'], name .. ' autostart not listed in files: ' .. autostart); + end + end +end); + +testlib.test('root manifest tracks trapos package version', function() + local manifest = readJson('/trapos/manifest.json'); + local trapos = readJson(PACKAGES_DIR .. '/trapos/ccpm.json'); + testlib.assertEquals(manifest.version, trapos.version); +end); + +testlib.run();