diff --git a/Taskfile.yml b/Taskfile.yml index cf0622ef..6bad3120 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -103,6 +103,51 @@ tasks: - echo "Warning - Installing packages over RNS via pip-rns is slow. Use for mesh-only environments." - bash scripts/pip-rns-deps.sh {{.CLI_ARGS}} + deps:git-pins: + aliases: [deps:pins] + desc: Fail if github/git deps or WASM download URLs drift from scripts/deps-allowlist.json + cmds: + - node scripts/check-git-deps.mjs + + deps:why: + desc: Explain why a package is installed (pnpm why and uv tree --package) + cmds: + - | + PKG="{{.CLI_ARGS}}" + if [ -z "$PKG" ]; then + echo "Usage: task deps:why -- " >&2 + exit 1 + fi + echo "=== pnpm why ${PKG} ===" + {{.NPM}} why "$PKG" || true + echo + echo "=== uv tree --package ${PKG} --invert ===" + uv tree --package "$PKG" --invert || true + + check:format-tokens: + desc: Fail if formatDate/meshDate patterns use unsupported datetime tokens + cmds: + - node scripts/check-format-tokens.mjs + + licenses:refresh: + desc: Regenerate embedded license artifacts and fail if the tree is dirty afterward + cmds: + - uv run python -m meshchatx.src.backend.licenses_collector --write-artifacts + - | + if ! git diff --quiet -- \ + meshchatx/src/backend/data/licenses_frontend.json \ + meshchatx/src/backend/data/licenses_backend.json \ + meshchatx/src/backend/data/THIRD_PARTY_NOTICES.txt + then + echo "licenses:refresh left a dirty tree. Commit the updated license artifacts." >&2 + git status --short -- \ + meshchatx/src/backend/data/licenses_frontend.json \ + meshchatx/src/backend/data/licenses_backend.json \ + meshchatx/src/backend/data/THIRD_PARTY_NOTICES.txt >&2 + exit 1 + fi + echo "licenses:refresh: artifacts up to date" + docs:rns: aliases: [build-docs:rns] desc: Fetch Reticulum manual from rngit website remote over RNS @@ -180,11 +225,13 @@ tasks: lint:frontend: aliases: [lint:fe] - desc: Lint frontend code (ESLint, vue-tsc, knip) + desc: Lint frontend code (ESLint, vue-tsc, knip, format-token and git-dep pin checks) cmds: - "{{.NPM}} run lint" - "{{.NPM}} run typecheck" - "{{.NPM}} exec knip" + - task: check:format-tokens + - task: deps:git-pins lint:backend: aliases: [lint:be] @@ -283,6 +330,30 @@ tasks: cmds: - node scripts/mutation/run.mjs --source meshchatx/src/frontend/js/rnode/Capabilities.js --max-per-file 20 + test:mutation:frontend:libs: + desc: MeshMut on meshchatx/src/frontend/libs (emitter uuid datetime clickOutside) + cmds: + - >- + node scripts/mutation/run.mjs + --source meshchatx/src/frontend/libs/emitter.js + --source meshchatx/src/frontend/libs/uuid.js + --source meshchatx/src/frontend/libs/datetime.js + --source meshchatx/src/frontend/libs/clickOutside.js + {{.CLI_ARGS}} + + test:libs: + aliases: [test:fe:libs] + desc: Run in-repo frontend libs tests with coverage gate + cmds: + - >- + {{.NPM}} exec vitest run + tests/frontend/libs.core.test.js + tests/frontend/libs.oracle.test.js + tests/frontend/libs.index.test.js + --coverage + --coverage.include=meshchatx/src/frontend/libs/** + - node scripts/check-format-tokens.mjs + test:frontend: aliases: [test:fe] desc: Run frontend + Electron shell unit tests (vitest, excludes i18n, see test:lang, excludes LoadTimePerformance, use test:fe:loadtime locally) diff --git a/meshchatx.rsm b/meshchatx.rsm index 238da675..b5cf4a0d 100644 Binary files a/meshchatx.rsm and b/meshchatx.rsm differ diff --git a/package.json b/package.json index 51d122e7..81b164ff 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,7 @@ "globals": "^17.6.0", "jsdom": "^29.1.1", "knip": "^6.24.0", - "micron-parser": "github:RFnexus/micron-parser-js", + "micron-parser": "github:RFnexus/micron-parser-js#33feb1054c8b2cb3f5f05abbb8903360d3f0c098", "prettier": "^3.9.3", "tailwindcss": "^4.3.0", "terser": "^5.48.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c2ff9c10..94dea4cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -193,7 +193,7 @@ importers: specifier: ^6.24.0 version: 6.24.0 micron-parser: - specifier: github:RFnexus/micron-parser-js + specifier: github:RFnexus/micron-parser-js#33feb1054c8b2cb3f5f05abbb8903360d3f0c098 version: https://codeload.github.com/RFnexus/micron-parser-js/tar.gz/33feb1054c8b2cb3f5f05abbb8903360d3f0c098 prettier: specifier: ^3.9.3 @@ -4270,7 +4270,7 @@ snapshots: '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 - string-width-cjs: string-width@4.2.0 + string-width-cjs: string-width@4.2.3 strip-ansi: 6.0.1 strip-ansi-cjs: strip-ansi@6.0.1 wrap-ansi: 8.1.0 diff --git a/scripts/check-format-tokens.mjs b/scripts/check-format-tokens.mjs new file mode 100644 index 00000000..f8b205e3 --- /dev/null +++ b/scripts/check-format-tokens.mjs @@ -0,0 +1,125 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: 0BSD +/** + * Fail when formatDate / meshDate(...).format patterns use unsupported tokens. + * Keeps meshchatx/src/frontend/libs/datetime.js as the single source of truth. + */ +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, ".."); +const FRONTEND_ROOT = path.join(ROOT, "meshchatx", "src", "frontend"); +const DATETIME_LIB = path.join(FRONTEND_ROOT, "libs", "datetime.js"); + +const SCAN_EXTENSIONS = new Set([".js", ".mjs", ".cjs", ".vue", ".ts", ".tsx"]); + +/** @type {RegExp[]} */ +const PATTERN_EXTRACTORS = [ + /\bformatDate\s*\(\s*[^,]+?\s*,\s*(['"`])([^'"`]+?)\1/g, + /\bmeshDate\s*\(\s*[^)]*?\)\s*\.\s*format\s*\(\s*(['"`])([^'"`]+?)\1/g, + /\bdayjs\s*\(\s*[^)]*?\)\s*\.\s*format\s*\(\s*(['"`])([^'"`]+?)\1/g, +]; + +/** + * @param {string} source + * @returns {string[]} + */ +function readSupportedTokens(source) { + const match = source.match(/export const SUPPORTED_FORMAT_TOKENS = Object\.freeze\(\[([\s\S]*?)\]\)/); + if (!match) { + return []; + } + return Array.from(match[1].matchAll(/"([^"]+)"/g), (m) => m[1]); +} + +/** + * @param {string} dir + * @param {(filePath: string) => void} visit + */ +function walk(dir, visit) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.name === "node_modules" || entry.name === "public" || entry.name.startsWith(".")) { + continue; + } + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(full, visit); + continue; + } + if (SCAN_EXTENSIONS.has(path.extname(entry.name))) { + visit(full); + } + } +} + +/** + * @param {string} pattern + * @param {ReadonlySet} supported + * @returns {string[]} + */ +function unsupportedTokens(pattern, supported) { + const tokenRe = /YYYY|MMM|MM|M|DD|D|HH|H|hh|h|mm|A|a|ss|SSS|Z|z|X|x|Do|ddd|dddd/g; + const found = pattern.match(tokenRe) || []; + return [...new Set(found.filter((token) => !supported.has(token)))]; +} + +function main() { + const datetimeSource = fs.readFileSync(DATETIME_LIB, "utf8"); + const supportedList = readSupportedTokens(datetimeSource); + if (supportedList.length === 0) { + console.error("check-format-tokens: SUPPORTED_FORMAT_TOKENS missing from datetime.js"); + process.exit(1); + } + const supported = new Set(supportedList); + + /** @type {{ file: string, pattern: string, tokens: string[] }[]} */ + const failures = []; + /** @type {{ file: string, pattern: string }[]} */ + const dayjsHits = []; + + walk(FRONTEND_ROOT, (filePath) => { + const rel = path.relative(ROOT, filePath).replace(/\\/g, "/"); + if (rel.endsWith("libs/datetime.js")) { + return; + } + const text = fs.readFileSync(filePath, "utf8"); + for (const extractor of PATTERN_EXTRACTORS) { + extractor.lastIndex = 0; + let match; + while ((match = extractor.exec(text)) !== null) { + const pattern = match[2]; + if (extractor.source.includes("dayjs")) { + dayjsHits.push({ file: rel, pattern }); + } + const bad = unsupportedTokens(pattern, supported); + if (bad.length > 0) { + failures.push({ file: rel, pattern, tokens: bad }); + } + } + } + }); + + if (dayjsHits.length > 0) { + console.error("check-format-tokens: dayjs(...).format still used in frontend sources:"); + for (const hit of dayjsHits) { + console.error(` ${hit.file}: ${hit.pattern}`); + } + process.exit(1); + } + + if (failures.length > 0) { + console.error("check-format-tokens: unsupported format tokens:"); + for (const failure of failures) { + console.error(` ${failure.file}: "${failure.pattern}" -> ${failure.tokens.join(", ")}`); + } + console.error(`Supported tokens: ${supportedList.join(" ")}`); + process.exit(1); + } + + console.log("check-format-tokens: ok"); +} + +main(); diff --git a/scripts/check-git-deps.mjs b/scripts/check-git-deps.mjs new file mode 100644 index 00000000..4f9abdfd --- /dev/null +++ b/scripts/check-git-deps.mjs @@ -0,0 +1,267 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: 0BSD +/** + * Fail when git-hosted deps or remote download URLs drift from scripts/deps-allowlist.json. + * Covers package.json github:/git+ specs, pnpm-lock pins, flatpak git sources, and Micron WASM URLs. + */ +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import { MICRON_PARSER_GO_RELEASE_TAG } from "./micron-parser-go-version.mjs"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, ".."); +const ALLOWLIST_PATH = path.join(ROOT, "scripts", "deps-allowlist.json"); +const PACKAGE_JSON_PATH = path.join(ROOT, "package.json"); +const LOCKFILE_PATH = path.join(ROOT, "pnpm-lock.yaml"); +const FETCH_MICRON_WASM_PATH = path.join(ROOT, "scripts", "fetch-micron-wasm.mjs"); + +const GIT_SPEC_RE = /^(github:|git\+|git:)/i; +const FULL_COMMIT_RE = /^[0-9a-f]{40}$/i; +const CODELOAD_COMMIT_RE = /codeload\.github\.com\/[^/]+\/[^/]+\/tar\.gz\/([0-9a-f]{40})/i; + +/** + * @param {string} pattern + * @param {string} value + */ +function matchPattern(pattern, value) { + const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*"); + return new RegExp(`^${escaped}$`).test(value); +} + +/** + * @param {string} spec + */ +function splitGitSpec(spec) { + const hashIndex = spec.indexOf("#"); + if (hashIndex < 0) { + return { base: spec, ref: null }; + } + return { + base: spec.slice(0, hashIndex), + ref: spec.slice(hashIndex + 1), + }; +} + +/** + * @param {string} lockText + * @param {string} name + */ +function lockfileCommitForPackage(lockText, name) { + const lines = lockText.split(/\r?\n/); + let inPackage = false; + for (const line of lines) { + if (line.match(new RegExp(`^\\s{4}${name}:\\s*$`))) { + inPackage = true; + continue; + } + if (inPackage) { + if (/^\s{4}\S/.test(line) && !line.trim().startsWith(`${name}:`)) { + break; + } + const versionMatch = line.match(/^\s+version:\s+(\S+)\s*$/); + if (versionMatch) { + const codeload = versionMatch[1].match(CODELOAD_COMMIT_RE); + if (codeload) { + return codeload[1].toLowerCase(); + } + } + const resolutionMatch = line.match(/^\s+resolution:\s+\{[^}]*tarball:\s+(\S+?)[,}]/); + if (resolutionMatch) { + const codeload = resolutionMatch[1].match(CODELOAD_COMMIT_RE); + if (codeload) { + return codeload[1].toLowerCase(); + } + } + } + } + + const globalCodeload = lockText.match( + new RegExp(`${name}@https://codeload\\.github\\.com/[^\\s]+/tar\\.gz/([0-9a-f]{40})`, "i") + ); + if (globalCodeload) { + return globalCodeload[1].toLowerCase(); + } + return null; +} + +/** + * @param {string} text + * @param {string} constName + */ +function readExportedStringConst(text, constName) { + const re = new RegExp(`const\\s+${constName}\\s*=\\s*[\`"']([^\`"']+)[\`"']`); + const match = text.match(re); + return match ? match[1] : null; +} + +function main() { + /** @type {string[]} */ + const errors = []; + + if (!fs.existsSync(ALLOWLIST_PATH)) { + console.error(`check-git-deps: missing allowlist ${path.relative(ROOT, ALLOWLIST_PATH)}`); + process.exit(1); + } + + const allowlist = JSON.parse(fs.readFileSync(ALLOWLIST_PATH, "utf8")); + const packageJson = JSON.parse(fs.readFileSync(PACKAGE_JSON_PATH, "utf8")); + const lockText = fs.readFileSync(LOCKFILE_PATH, "utf8"); + const fetchMicronText = fs.readFileSync(FETCH_MICRON_WASM_PATH, "utf8"); + + const depSections = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]; + /** @type {{ name: string, spec: string }[]} */ + const gitDeps = []; + for (const section of depSections) { + const block = packageJson[section]; + if (!block || typeof block !== "object") continue; + for (const [name, spec] of Object.entries(block)) { + if (typeof spec === "string" && GIT_SPEC_RE.test(spec)) { + gitDeps.push({ name, spec }); + } + } + } + + const allowByName = new Map( + (allowlist.git_dependencies || []).map((entry) => [entry.name, entry]) + ); + + for (const dep of gitDeps) { + const allowed = allowByName.get(dep.name); + if (!allowed) { + errors.push(`git dependency "${dep.name}" (${dep.spec}) is not in deps-allowlist.json`); + continue; + } + + const { base, ref } = splitGitSpec(dep.spec); + const allowedBases = new Set( + (allowed.specifiers || []).map((spec) => splitGitSpec(spec).base) + ); + if (!allowedBases.has(base) && !(allowed.specifiers || []).includes(dep.spec)) { + errors.push( + `git dependency "${dep.name}" specifier base "${base}" is not allowlisted (have: ${(allowed.specifiers || []).join(", ")})` + ); + } + + const lockCommit = lockfileCommitForPackage(lockText, dep.name); + if (!lockCommit) { + errors.push(`git dependency "${dep.name}" is unpinned in pnpm-lock.yaml (no 40-char commit)`); + continue; + } + if (!FULL_COMMIT_RE.test(lockCommit)) { + errors.push(`git dependency "${dep.name}" lock commit is not a full SHA: ${lockCommit}`); + continue; + } + if (lockCommit !== String(allowed.commit).toLowerCase()) { + errors.push( + `git dependency "${dep.name}" lock commit ${lockCommit} drifts from allowlist ${allowed.commit}` + ); + } + if (ref && FULL_COMMIT_RE.test(ref) && ref.toLowerCase() !== String(allowed.commit).toLowerCase()) { + errors.push( + `git dependency "${dep.name}" package.json ref #${ref} drifts from allowlist ${allowed.commit}` + ); + } + if (!ref) { + errors.push( + `git dependency "${dep.name}" package.json specifier is floating (${dep.spec}). Pin with #${allowed.commit}` + ); + } + } + + for (const allowed of allowlist.git_dependencies || []) { + if (!gitDeps.some((dep) => dep.name === allowed.name)) { + errors.push( + `allowlist git dependency "${allowed.name}" is missing from package.json (remove from allowlist or restore dep)` + ); + } + } + + const flatpakModules = packageJson.build?.flatpak?.modules || []; + const allowGitSources = allowlist.git_sources || []; + /** @type {object[]} */ + const declaredGitSources = []; + for (const mod of flatpakModules) { + for (const source of mod.sources || []) { + if (source && source.type === "git" && source.url) { + declaredGitSources.push(source); + } + } + } + + for (const source of declaredGitSources) { + const allowed = allowGitSources.find((entry) => entry.url === source.url); + if (!allowed) { + errors.push(`flatpak git source ${source.url} is not in deps-allowlist.json`); + continue; + } + if (allowed.commit && source.commit && source.commit !== allowed.commit) { + errors.push( + `flatpak git source ${source.url} commit ${source.commit} drifts from allowlist ${allowed.commit}` + ); + } + if (allowed.tag && source.tag && source.tag !== allowed.tag) { + errors.push( + `flatpak git source ${source.url} tag ${source.tag} drifts from allowlist ${allowed.tag}` + ); + } + if (!source.commit) { + errors.push(`flatpak git source ${source.url} is missing commit pin`); + } + } + + for (const allowed of allowGitSources) { + if (!declaredGitSources.some((source) => source.url === allowed.url)) { + errors.push( + `allowlist git source ${allowed.url} is missing from package.json flatpak modules` + ); + } + } + + const wasmUrlTemplate = readExportedStringConst(fetchMicronText, "DEFAULT_WASM_URL"); + const wasmExecUrl = readExportedStringConst(fetchMicronText, "DEFAULT_WASM_EXEC_URL"); + // DEFAULT_WASM_URL is a template literal with ${MICRON_PARSER_GO_RELEASE_TAG} + const wasmUrlResolved = `https://github.com/Quad4-Software/Micron-Parser-Go/releases/download/${MICRON_PARSER_GO_RELEASE_TAG}/micron-parser-go.wasm`; + + for (const entry of allowlist.download_urls || []) { + if (entry.name === "micron-parser-go-wasm") { + if (entry.pinned_tag && entry.pinned_tag !== MICRON_PARSER_GO_RELEASE_TAG) { + errors.push( + `Micron WASM tag ${MICRON_PARSER_GO_RELEASE_TAG} drifts from allowlist ${entry.pinned_tag}` + ); + } + if (entry.pattern && !matchPattern(entry.pattern, wasmUrlResolved)) { + errors.push( + `Micron WASM URL ${wasmUrlResolved} does not match allowlist pattern ${entry.pattern}` + ); + } + if (wasmUrlTemplate && !wasmUrlTemplate.includes("Micron-Parser-Go/releases/download/")) { + errors.push(`fetch-micron-wasm.mjs DEFAULT_WASM_URL looks unexpected: ${wasmUrlTemplate}`); + } + } + if (entry.name === "golang-wasm-exec") { + if (!wasmExecUrl) { + errors.push("fetch-micron-wasm.mjs DEFAULT_WASM_EXEC_URL missing"); + } else if (entry.url && wasmExecUrl !== entry.url) { + errors.push( + `wasm_exec URL ${wasmExecUrl} drifts from allowlist ${entry.url}` + ); + } + } + } + + if (errors.length > 0) { + console.error("check-git-deps: failed"); + for (const error of errors) { + console.error(` - ${error}`); + } + process.exit(1); + } + + console.log("check-git-deps: ok"); + console.log(` micron-parser commit: ${(allowByName.get("micron-parser") || {}).commit}`); + console.log(` micron wasm tag: ${MICRON_PARSER_GO_RELEASE_TAG}`); +} + +main(); diff --git a/scripts/deps-allowlist.json b/scripts/deps-allowlist.json new file mode 100644 index 00000000..b0328868 --- /dev/null +++ b/scripts/deps-allowlist.json @@ -0,0 +1,35 @@ +{ + "version": 1, + "git_dependencies": [ + { + "name": "micron-parser", + "specifiers": [ + "github:RFnexus/micron-parser-js", + "github:RFnexus/micron-parser-js#33feb1054c8b2cb3f5f05abbb8903360d3f0c098" + ], + "commit": "33feb1054c8b2cb3f5f05abbb8903360d3f0c098", + "repository": "https://github.com/RFnexus/micron-parser-js" + } + ], + "git_sources": [ + { + "name": "zypak", + "url": "https://github.com/refi64/zypak", + "tag": "v2025.09", + "commit": "693a71c5ffa80ec9c9ce2ae03b1ccc493c698e53", + "declared_in": "package.json#build.flatpak.modules" + } + ], + "download_urls": [ + { + "name": "micron-parser-go-wasm", + "pattern": "https://github.com/Quad4-Software/Micron-Parser-Go/releases/download/*/micron-parser-go.wasm", + "pinned_tag": "v1.0.5", + "tag_source": "scripts/micron-parser-go-version.mjs" + }, + { + "name": "golang-wasm-exec", + "url": "https://raw.githubusercontent.com/golang/go/go1.26.2/lib/wasm/wasm_exec.js" + } + ] +} diff --git a/scripts/mutation/config.mjs b/scripts/mutation/config.mjs index 1fbd0cfc..b26650f2 100644 --- a/scripts/mutation/config.mjs +++ b/scripts/mutation/config.mjs @@ -4,6 +4,12 @@ /** @typedef {{ source: string, tests: string[] }} MutationTarget */ +const LIBS_TESTS = [ + "tests/frontend/libs.core.test.js", + "tests/frontend/libs.oracle.test.js", + "tests/frontend/libs.index.test.js", +]; + /** @type {MutationTarget[]} */ export const DEFAULT_FRONTEND_TARGETS = [ { @@ -26,6 +32,22 @@ export const DEFAULT_FRONTEND_TARGETS = [ source: "meshchatx/src/frontend/js/reticulumPathfinding.js", tests: ["tests/frontend/reticulumPathfinding.test.js"], }, + { + source: "meshchatx/src/frontend/libs/emitter.js", + tests: LIBS_TESTS, + }, + { + source: "meshchatx/src/frontend/libs/uuid.js", + tests: LIBS_TESTS, + }, + { + source: "meshchatx/src/frontend/libs/datetime.js", + tests: LIBS_TESTS, + }, + { + source: "meshchatx/src/frontend/libs/clickOutside.js", + tests: LIBS_TESTS, + }, ]; /**