From 57a361ef17002ea32bec80a9fbc81de64c451658 Mon Sep 17 00:00:00 2001 From: Stephen Hellicar Date: Sun, 12 Apr 2026 08:41:33 +1000 Subject: [PATCH 1/6] Add dependency scripts. --- .claude/CLAUDE.md | 85 ++++++--------------- pnpm-workspace.yaml | 2 +- scripts/check-deps.sh | 52 +++++++++++++ scripts/fix-audit.sh | 97 ++++++++++++++++++++++++ scripts/fix-ghsa.mjs | 171 ++++++++++++++++++++++++++++++++++++++++++ scripts/package.json | 2 + 6 files changed, 346 insertions(+), 63 deletions(-) create mode 100755 scripts/check-deps.sh create mode 100755 scripts/fix-audit.sh create mode 100755 scripts/fix-ghsa.mjs diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 8dca07f..2a0cca1 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -5,88 +5,49 @@ ## Identity -The fleet exists so that every session can remain laser focused. +You are a worker. Your job is one cast — one task, one repository, one goal. -Workers are where the work happens. Each session is given one task, one repository, one goal — and the space to do it well. +Each cast is its own clean shot at success. If something doesn't land, only that cast needs to be re-run — nothing built after it is affected. -Each session is its own clean shot at success. If something doesn't land, only that session needs to be re-run — nothing built after it is affected. - -Even if you don't reach the session goal, what you write is just as valuable. Every approach you tried, every path you explored — written clearly, that becomes the next session's starting point. The context disappears when this session ends. The knowledge doesn't have to. +Even if you don't reach the goal, what you leave behind is just as valuable. Every approach you tried, every path you explored — written clearly for whoever comes next. The context disappears when this cast ends. What you write does not. This is your testament. The fleet has four roles: - **Fleet Manager (FM)**: maintains the templates and tooling that reach you through this harness. Your operating environment comes from the FM. -- **Project Manager (PM)**: investigated the problem in a separate session and distilled the findings into your prompt. This session starts focused because that work is already done. Reads your session brief and directs what comes next. -- **Worker**: you. One task, one repository, one goal. -- **Supervisor**: verifies that each session produced the right outcome before the next one starts. Currently the Supreme Commander. +- **Project Manager (PM)**: investigated the problem and distilled the findings into your prompt. +- **Worker**: you. One cast, one task, one repository, one goal. +- **Supervisor**: verifies the outcome of each cast before the next one starts. Currently the Supreme Commander. - -## Why This Harness Exists - -Each session starts with a blank slate. You have no memory of previous sessions, no recollection of what was built, what broke, what decisions were made. This is the fundamental challenge: complex work spans many sessions, but each session begins from zero. - -Without structure, two failure patterns emerge. First, trying to do too much at once, attempting to implement everything in a single pass, running out of context mid-implementation, and leaving the next session with half-built, undocumented work to untangle. Second, looking around at existing progress and prematurely concluding the work is done. - -The harness and session logs exist to solve this. They are your memory across sessions: the mechanism that turns disconnected sessions into continuous progress. - -**How the pattern works:** - -- **On start**: Read the harness and recent session logs to understand current state, architecture, conventions, and what was last worked on. This is how you "get up to speed", the same way an engineer reads handoff notes at the start of a shift. -- **During work**: Work on one thing at a time. Finish it, verify it works, commit it in a clean state. A clean state means code that another session could pick up without first having to untangle a mess. Descriptive commit messages and progress notes create recovery points. If something goes wrong, there is a known-good state to return to. -- **On finish**: Write a next session brief in `.claude/sessions/YYYY-MM-DD.md`. Not a record of what you did — the next session reads git log for that. Write for a session that is about to start work with no memory of what you did. What do they need to know *before* they touch anything? Hard constraints, half-finished things, traps, why a decision was made. Write constraints as constraints, not lessons: - - > ❌ "Learned that the CI workflow file is called `node.js.yml`" - > ✅ "The CI workflow is `node.js.yml`. Do not rename it — the badge URL is hardcoded to that name." - - The bad version is a retrospective. A future session skims it and doesn't absorb it. The good version is an instruction with a reason. It reads like something that matters. - -**Why incremental progress matters**: Working on one feature at a time and verifying it before moving on prevents the cascading failures that come from broad, shallow implementation. It also means each commit represents a working state of the codebase. - -**Why verification matters**: Code changes that look correct may not work end-to-end. Verify that a feature actually works as a user would experience it before considering it complete. Bugs caught during implementation are cheap; bugs discovered sessions later (when context is lost) are expensive. - -The harness is deliberately structured. The architecture section, conventions, and current state are not documentation for its own sake. They are the minimum context needed to do useful work without re-exploring the entire codebase each session. - - - -## Never Guess - -If you do not have enough information to do something, stop and ask. Do not guess. Do not infer. Do not fill in blanks with what seems reasonable. + +## Your Testament -This applies to everything: requirements, API behavior, architectural decisions, file locations, conventions, git state, file contents, whether a change is related to your work. If you are not certain, you do not know. Act accordingly. +The work you do in this cast matters. What you discover along the way matters more. -**Guessing includes not looking.** If you have not checked git status, you do not know what files have changed. If you have not read a file, you do not know what it contains. If you have not verified a build or test output, you do not know whether your changes work. Assuming something is true without checking is a guess. Dismissing something as unrelated without reading it is a guess. Every tool you have exists so you do not need to guess. Use them. +Most prompts span multiple casts. The knowledge you build up during a cast disappears when it ends. Your testament is how it survives. -Guessing is poison. A guessed assumption becomes a code decision. Other code builds on that decision. Future sessions read that code and treat it as intentional. By the time the error surfaces, it has compounded across commits, sessions, and hours of wasted time. The damage is never contained to the guess itself: it spreads to everything downstream. +**Mechanics** -A question costs one message. A look costs one tool call. A guess costs everything built on top of it. - +Run `date '+%Y-%m-%d %H:%M'` to get the current time. - -## Session Protocol +At the start of your cast, read previous testaments. They are the context you don't have. -Every session has three phases: start, work, end. +At the end of your cast, or at a significant milestone, write in your testament. The file is `.claude/testament/YYYY-MM-DD.md`. If it exists, append at the bottom. If it doesn't, create it. Format each entry with the time as the header: -### Session Start +``` +# HH:mm +``` -1. Read this file -2. Find recent session logs: `find .claude/sessions -name '*.md' 2>/dev/null | sort -r | head -5` -3. Read session logs found. Understand current state before doing anything. -4. Create or switch to the correct branch (if specified in prompt) -5. Build your TODO list from the prompt, present it before starting work +The git log records what happened. The code shows what exists. Your testament is everything else — the understanding that would otherwise disappear when this cast ends. -### Work +**What to write** -- Work one task at a time. Mark each in-progress, then completed. -- If a task is dropped, mark it `[-]` with a brief reason +Think about what helped you from reading previous testaments — write more of that. -### Session End +Think about what didn't help — don't write that. -1. Write a next session brief to `.claude/sessions/YYYY-MM-DD.md`. Write it for a session that starts with no memory of what you did. The question is not "what happened" — git log shows that. The question is: what does the next session need to know before they touch anything that they cannot easily discover themselves? Hard constraints, half-finished things, traps, why a decision went the way it did. Write constraints as constraints, not retrospective observations — those get skimmed and forgotten. -2. Update `Current State` below if branch or in-progress work changed -3. Update `Recent Decisions` below if you made an architectural decision -4. Commit session log and state updates together - +Write what you know that the code doesn't say. + ## Current State diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a5a229c..a8ef271 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,4 +3,4 @@ packages: - scripts - examples/*/* overrides: - cookie@<0.7.0: '>=0.7.0' + 'cookie@<0.7.0': '>=0.7.0' diff --git a/scripts/check-deps.sh b/scripts/check-deps.sh new file mode 100755 index 0000000..c3543e9 --- /dev/null +++ b/scripts/check-deps.sh @@ -0,0 +1,52 @@ +#!/bin/sh +# Check dependency staleness across the @shellicar/ecosystem workspace. +# Outputs JSON with patch/minor/major outdated counts and a score. +# +# Scoring (compound decay per outdated dependency): +# patch: × 0.90 each (easy to apply, no excuse) +# minor: × 0.93 each +# major: × 0.97 each (breaking changes, intentionally deferrable) +# +# Scores compound — each additional outdated package multiplies the remainder, +# so they stack harder as they pile up. +# +# Usage: +# check-deps.sh + +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +WORKSPACE_DIR="$(dirname "$SCRIPT_DIR")" + +cd "$WORKSPACE_DIR" + +set +e +outdated_output=$(pnpm outdated --recursive --json 2>/dev/null) +set -e + +if [ -z "$outdated_output" ] || [ "$outdated_output" = "{}" ]; then + printf '{"patch":0,"minor":0,"major":0,"total":0,"pct":100}\n' + exit 0 +fi + +printf '%s' "$outdated_output" | node -e " + let d = ''; process.stdin.on('data', c => d += c).on('end', () => { + try { + const data = JSON.parse(d); + const pkgs = Array.isArray(data) ? data : Object.values(data); + let patch = 0, minor = 0, major = 0; + for (const info of pkgs) { + const cur = (info.current || '0').split('.').map(Number); + const lat = (info.latest || info.wanted || '0').split('.').map(Number); + if (lat[0] > cur[0]) major++; + else if (lat[1] > cur[1]) minor++; + else if (lat[2] > cur[2]) patch++; + } + const total = patch + minor + major; + const pct = Math.round(100 * Math.pow(0.90, patch) * Math.pow(0.93, minor) * Math.pow(0.97, major)); + process.stdout.write(JSON.stringify({patch, minor, major, total, pct}) + '\n'); + } catch(e) { + process.stdout.write('{\"patch\":0,\"minor\":0,\"major\":0,\"total\":0,\"pct\":100}\n'); + } + }); +" diff --git a/scripts/fix-audit.sh b/scripts/fix-audit.sh new file mode 100755 index 0000000..b13b35c --- /dev/null +++ b/scripts/fix-audit.sh @@ -0,0 +1,97 @@ +#!/bin/sh +# Fix pnpm audit vulnerabilities with clean override resolution. +# +# Runs pnpm audit --fix, then nukes lockfile + node_modules and +# reinstalls to work around the pnpm override chaining bug where +# overrides don't re-evaluate after a first override changes resolution. +# +# See: https://github.com/pnpm/pnpm/issues/6774 +# +# Usage: +# fix-audit.sh # Run from anywhere — navigates to workspace root +# fix-audit.sh --check # Verify audit is clean (no fix) +# +# Exit codes: +# 0 Audit is clean (after fix, or already clean) +# 1 Audit still has vulnerabilities after fix + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +WORKSPACE_DIR="$(dirname "$SCRIPT_DIR")" + +CHECK_ONLY=0 + +while [ $# -gt 0 ]; do + case "$1" in + --check) + CHECK_ONLY=1 + shift + ;; + -h|--help) + sed -n '/^#/!q;s/^# \{0,1\}//p' "$0" | tail -n +2 + exit 0 + ;; + *) + printf "❌ Unknown option: %s\n" "$1" >&2 + exit 1 + ;; + esac +done + +cd "$WORKSPACE_DIR" + +# ── Check-only mode ────────────────────────────────────────────────── + +if [ "$CHECK_ONLY" -eq 1 ]; then + printf "🔍 Checking audit status...\n" + set +e + pnpm audit 2>&1 + status=$? + set -e + if [ "$status" -eq 0 ]; then + printf "✅ Audit is clean\n" + exit 0 + else + printf "❌ Audit has vulnerabilities\n" + exit 1 + fi +fi + +# ── Fix mode ───────────────────────────────────────────────────────── + +printf "🔧 Running pnpm audit --fix...\n" +set +e +pnpm audit --fix 2>&1 +set -e + +# pnpm override chaining bug workaround: +# When multiple overrides exist for the same package at different version +# ranges (e.g. koa@<2.16.4 and koa@>=3.0.0), pnpm doesn't re-evaluate +# the second override after the first one changes the resolved version. +# The only reliable fix is to delete both pnpm-lock.yaml AND node_modules +# then do a clean install. +# +# See: https://github.com/pnpm/pnpm/issues/6774 +printf "\n🔄 Removing lockfile and node_modules for clean override resolution...\n" +printf " (workaround for https://github.com/pnpm/pnpm/issues/6774)\n" +rm -f pnpm-lock.yaml +rm -rf node_modules +printf "📦 Reinstalling...\n" +pnpm install 2>&1 + +# ── Verify ─────────────────────────────────────────────────────────── + +printf "\n🔍 Verifying audit...\n" +set +e +pnpm audit 2>&1 +status=$? +set -e + +if [ "$status" -eq 0 ]; then + printf "\n✅ Audit is clean\n" + exit 0 +else + printf "\n❌ Audit still has vulnerabilities after fix\n" >&2 + exit 1 +fi diff --git a/scripts/fix-ghsa.mjs b/scripts/fix-ghsa.mjs new file mode 100755 index 0000000..f5c963e --- /dev/null +++ b/scripts/fix-ghsa.mjs @@ -0,0 +1,171 @@ +#!/usr/bin/env node +// Apply targeted pnpm overrides from GHSA vulnerability data. +// +// Reads vulnerability data from stdin as JSON array: +// [{"pkg":"koa","vulnerable":">= 3.0.0, < 3.1.2","patched":"3.1.2"}, ...] +// +// Updates pnpm-workspace.yaml overrides section: +// - Adds new overrides for unhandled vulnerable ranges +// - Supersedes stale overrides (same package, contained range, lower patch) +// - Removes overrides that are fully covered by a new wider range +// +// Usage: +// echo '[...]' | node scripts/fix-ghsa.mjs + +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import semver from "semver"; +import YAML from "yaml"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const workspacePath = join(__dirname, "..", "pnpm-workspace.yaml"); + +// ── Read inputs ───────────────────────────────────────────────────── + +const input = readFileSync("/dev/stdin", "utf8"); +const vulnerabilities = JSON.parse(input); + +if (!Array.isArray(vulnerabilities) || vulnerabilities.length === 0) { + process.stderr.write("No vulnerability data on stdin\n"); + process.exit(1); +} + +const raw = readFileSync(workspacePath, "utf8"); +const doc = YAML.parseDocument(raw, { schema: "failsafe" }); +const overridesNode = doc.get("overrides", true); +const merged = new Map(); +if (overridesNode && YAML.isPair(overridesNode) || YAML.isMap(overridesNode)) { + for (const pair of overridesNode.items) { + merged.set(String(pair.key), String(pair.value)); + } +} else if (overridesNode) { + // Fallback: plain object parse + const parsed = YAML.parse(raw, { schema: "failsafe" }); + if (parsed.overrides) { + for (const [k, v] of Object.entries(parsed.overrides)) { + merged.set(k, v); + } + } +} +const actions = []; + +// ── Helpers ───────────────────────────────────────────────────────── + +// Convert API range ">= 3.0.0, < 3.1.2" to pnpm format ">=3.0.0 <3.1.2" +function normalizeRange(range) { + return range + .replace(/,\s*/g, " ") + .replace(/(>=|<=|>|<)\s+/g, "$1") + .trim(); +} + +// Parse override key "koa@>=3.0.0 <3.1.2" -> { pkg: "koa", range: ">=3.0.0 <3.1.2" } +function parseKey(key) { + let atIdx; + if (key.startsWith("@")) { + atIdx = key.indexOf("@", 1); + } else { + atIdx = key.indexOf("@"); + } + if (atIdx === -1) return { pkg: key, range: "" }; + return { pkg: key.substring(0, atIdx), range: key.substring(atIdx + 1) }; +} + +// Extract lower bound version: ">=9.0.0 <9.0.7" -> "9.0.0", "<2.16.4" -> null +function lowerBound(range) { + const m = range.match(/>=([\d.]+[-\w.]*)/); + return m ? m[1] : null; +} + +// Extract upper bound version: ">=9.0.0 <9.0.7" -> "9.0.7", "<2.16.4" -> "2.16.4" +function upperBound(range) { + const m = range.match(/<=?([\d.]+[-\w.]*)/); + return m ? m[1] : null; +} + +// Extract patched version from value: ">=9.0.7" -> "9.0.7" +function patchedVer(value) { + const m = value.match(/>=?([\d.]+[-\w.]*)/); + return m ? m[1] : null; +} + +// Does rangeA fully contain rangeB? (for same package) +// e.g. "<2.16.4" contains ">=2.16.2 <2.16.3" +function rangeContains(outerRange, innerRange) { + const oLo = lowerBound(outerRange); + const oHi = upperBound(outerRange); + const iLo = lowerBound(innerRange); + const iHi = upperBound(innerRange); + + // Check lower: outer lower must be <= inner lower + // null lower = 0.0.0, covers everything below + if (oLo && iLo && semver.gt(oLo, iLo)) return false; + if (oLo && !iLo) return false; // outer has floor, inner doesn't + + // Check upper: outer upper must be >= inner upper + if (oHi && iHi && semver.gt(iHi, oHi)) return false; + if (!oHi && iHi) return true; // outer has no ceiling + if (oHi && !iHi) return false; // inner has no ceiling but outer does + + return true; +} + +// ── Build new overrides ───────────────────────────────────────────── + +for (const vuln of vulnerabilities) { + const range = normalizeRange(vuln.vulnerable); + const key = `${vuln.pkg}@${range}`; + const value = `>=${vuln.patched}`; + + // If exact same key+value already exists, nothing to do + if (merged.get(key) === value) continue; + + // Remove all existing overrides for the same package whose range is contained by the new one + for (const [existingKey] of merged) { + const existing = parseKey(existingKey); + if (existing.pkg !== vuln.pkg) continue; + if (existingKey === key) continue; // handled separately below + if (rangeContains(range, existing.range)) { + actions.push({ action: "remove", key: existingKey, value: merged.get(existingKey) }); + merged.delete(existingKey); + } + } + + // Add or update the override + merged.set(key, value); + actions.push({ action: "add", key, value }); +} + +// ── Write back ────────────────────────────────────────────────────── + +// Build a new YAML map node with proper quoting +const sortedEntries = [...merged.entries()].sort((a, b) => a[0].localeCompare(b[0])); +const newMap = new YAML.YAMLMap(); +for (const [k, v] of sortedEntries) { + const keyNode = new YAML.Scalar(k); + keyNode.type = YAML.Scalar.QUOTE_SINGLE; + const valNode = new YAML.Scalar(v); + valNode.type = YAML.Scalar.QUOTE_SINGLE; + newMap.add(new YAML.Pair(keyNode, valNode)); +} + +doc.set("overrides", newMap); +writeFileSync(workspacePath, doc.toString()); + +// ── Report ────────────────────────────────────────────────────────── + +for (const a of actions) { + if (a.action === "remove") { + process.stdout.write(` REMOVE ${a.key}: '${a.value}'\n`); + } else { + process.stdout.write(` ADD ${a.key}: '${a.value}'\n`); + } +} + +if (actions.length === 0) { + process.stdout.write(" No changes needed\n"); + process.exit(2); +} + +process.exit(0); diff --git a/scripts/package.json b/scripts/package.json index fe3b1f0..c3e573a 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -12,7 +12,9 @@ "@shellicar/typescript-config": "workspace:^", "@types/node": "^22", "ajv": "^8", + "semver": "^7.7.4", "tsx": "^4", + "yaml": "^2.8.3", "zod": "^4" } } From 55485572d9bd3b9736e0d8011daf05ada483e227 Mon Sep 17 00:00:00 2001 From: Stephen Hellicar Date: Sun, 12 Apr 2026 10:05:40 +1000 Subject: [PATCH 2/6] =?UTF-8?q?Rename=20dev=20=E2=86=92=20watch;=20drop=20?= =?UTF-8?q?build-version=20publish=20lifecycle=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dev script name implies a specific tool or workflow, but the purpose is just watching for changes during development — watch says what it does. All nine packages updated. build-version had prepublishOnly/postpublish hooks left over from when each package had its own release process. Publishing is now owned by @shellicar/changes; those hooks have no role here and would be confusing if they ever ran. --- packages/build-azure-local-settings/package.json | 4 ++-- packages/build-clean/package.json | 4 ++-- packages/build-graphql/package.json | 4 ++-- packages/build-version/package.json | 4 +--- packages/core-config/package.json | 4 ++-- packages/core-di/package.json | 4 ++-- packages/cosmos-query-builder/package.json | 4 ++-- packages/graphql-codegen-treeshake/package.json | 4 ++-- packages/svelte-adapter-azure-functions/package.json | 4 ++-- 9 files changed, 17 insertions(+), 19 deletions(-) diff --git a/packages/build-azure-local-settings/package.json b/packages/build-azure-local-settings/package.json index 2cf52ea..027c622 100644 --- a/packages/build-azure-local-settings/package.json +++ b/packages/build-azure-local-settings/package.json @@ -86,9 +86,9 @@ ], "scripts": { "build": "tsup", - "dev": "tsup --watch", "test": "vitest run", - "type-check": "tsc -p tsconfig.check.json" + "type-check": "tsc -p tsconfig.check.json", + "watch": "tsup --watch" }, "peerDependencies": { "esbuild": "*" diff --git a/packages/build-clean/package.json b/packages/build-clean/package.json index b20bf9b..5bd2295 100644 --- a/packages/build-clean/package.json +++ b/packages/build-clean/package.json @@ -185,8 +185,8 @@ ], "scripts": { "build": "tsup", - "dev": "tsup --watch", - "type-check": "tsc -p tsconfig.check.json" + "type-check": "tsc -p tsconfig.check.json", + "watch": "tsup --watch" }, "peerDependencies": { "@farmfe/core": ">=1", diff --git a/packages/build-graphql/package.json b/packages/build-graphql/package.json index 588c213..106ca50 100644 --- a/packages/build-graphql/package.json +++ b/packages/build-graphql/package.json @@ -193,9 +193,9 @@ ], "scripts": { "build": "tsup", - "dev": "tsup --watch", "type-check": "tsc -p tsconfig.check.json", - "test": "vitest run" + "test": "vitest run", + "watch": "tsup --watch" }, "peerDependencies": { "@farmfe/core": ">=1", diff --git a/packages/build-version/package.json b/packages/build-version/package.json index 85f2649..d263c77 100644 --- a/packages/build-version/package.json +++ b/packages/build-version/package.json @@ -185,11 +185,9 @@ ], "scripts": { "build": "tsup", - "dev": "tsup --watch src", "test": "vitest run", "type-check": "tsc -p tsconfig.check.json", - "prepublishOnly": "run-s build test ci", - "postpublish": "pnpm version --no-git-tag-version patch" + "watch": "tsup --watch src" }, "peerDependencies": { "@farmfe/core": ">=1", diff --git a/packages/core-config/package.json b/packages/core-config/package.json index 90cb3c5..c9d5d27 100644 --- a/packages/core-config/package.json +++ b/packages/core-config/package.json @@ -55,9 +55,9 @@ ], "scripts": { "build": "tsup-node", - "dev": "tsup-node --watch", "test": "vitest run", - "type-check": "tsc -p tsconfig.check.json" + "type-check": "tsc -p tsconfig.check.json", + "watch": "tsup-node --watch" }, "devDependencies": { "@shellicar/typescript-config": "workspace:*", diff --git a/packages/core-di/package.json b/packages/core-di/package.json index b6be12c..4eeb6bb 100644 --- a/packages/core-di/package.json +++ b/packages/core-di/package.json @@ -44,9 +44,9 @@ ], "scripts": { "build": "tsup", - "dev": "tsup --watch", "test": "vitest run", - "type-check": "tsc -p tsconfig.check.json" + "type-check": "tsc -p tsconfig.check.json", + "watch": "tsup --watch" }, "devDependencies": { "@abraham/reflection": "^0.13.0", diff --git a/packages/cosmos-query-builder/package.json b/packages/cosmos-query-builder/package.json index a108ab2..f91fefe 100644 --- a/packages/cosmos-query-builder/package.json +++ b/packages/cosmos-query-builder/package.json @@ -48,9 +48,9 @@ ], "scripts": { "build": "tsup-node", - "dev": "tsup-node --watch", "type-check": "tsc -p tsconfig.check.json", - "test": "vitest run" + "test": "vitest run", + "watch": "tsup-node --watch" }, "devDependencies": { "@azure/cosmos": "^4.9.1", diff --git a/packages/graphql-codegen-treeshake/package.json b/packages/graphql-codegen-treeshake/package.json index 634a49a..8c0e0b0 100644 --- a/packages/graphql-codegen-treeshake/package.json +++ b/packages/graphql-codegen-treeshake/package.json @@ -5,8 +5,8 @@ "main": "./dist/cjs/index.cjs", "scripts": { "build": "tsup", - "dev": "tsup --watch", - "type-check": "tsc -p tsconfig.check.json" + "type-check": "tsc -p tsconfig.check.json", + "watch": "tsup --watch" }, "keywords": [ "graphql-codegen", diff --git a/packages/svelte-adapter-azure-functions/package.json b/packages/svelte-adapter-azure-functions/package.json index 0a98695..81495fe 100644 --- a/packages/svelte-adapter-azure-functions/package.json +++ b/packages/svelte-adapter-azure-functions/package.json @@ -30,9 +30,9 @@ ], "scripts": { "build": "tsup", - "dev": "tsup --watch", "test": "vitest run", - "type-check": "tsc -p tsconfig.check.json" + "type-check": "tsc -p tsconfig.check.json", + "watch": "tsup --watch" }, "exports": { ".": { From 8da3383d87f1fc36721f29c406d66f8f2ca65bf5 Mon Sep 17 00:00:00 2001 From: Stephen Hellicar Date: Sun, 12 Apr 2026 10:06:03 +1000 Subject: [PATCH 3/6] Add per-package audit scripts and health dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four scripts for auditing ecosystem package quality: check-tsconfig.sh: resolves effective TypeScript config via tsc --showConfig (so inherited settings count) and scores verbatimModuleSyntax, moduleDetection, moduleResolution, isolatedDeclarations, src/ include, and tsconfig.check.json. audit-package-json.sh: checks that each package has the publishing metadata right for this monorepo — correct repository URL, homepage pattern, exports structure and ordering, publishConfig, required files, and build script. check-biome.sh: verifies per-package biome.json is wired to the workspace root config (extends: "//", root: false) and has intentional linter rule overrides beyond just recommended. health.sh: aggregator. Runs all three per-package scripts and check-deps, merges results by package name, and emits a single JSON object with combined scores per package plus workspace deps. Single-package mode also supported. All scripts output JSON, exit non-zero on errors (not warnings), and support both full-workspace and single-package invocation. --- scripts/audit-package-json.sh | 262 ++++++++++++++++++++++++++++++++++ scripts/check-biome.sh | 154 ++++++++++++++++++++ scripts/check-tsconfig.sh | 188 ++++++++++++++++++++++++ scripts/health.sh | 90 ++++++++++++ 4 files changed, 694 insertions(+) create mode 100755 scripts/audit-package-json.sh create mode 100755 scripts/check-biome.sh create mode 100755 scripts/check-tsconfig.sh create mode 100755 scripts/health.sh diff --git a/scripts/audit-package-json.sh b/scripts/audit-package-json.sh new file mode 100755 index 0000000..b22683d --- /dev/null +++ b/scripts/audit-package-json.sh @@ -0,0 +1,262 @@ +#!/bin/sh +# Audit package.json fields across @shellicar/ecosystem packages. +# Outputs JSON. +# +# Checks per package (100pts total): +# private: false 5pts +# type: module 10pts +# license: MIT 5pts +# author 5pts +# description 5pts +# keywords 5pts +# repository.url 10pts +# bugs.url 5pts +# homepage 5pts +# publishConfig 5pts +# exports structure 10pts +# exports order 5pts +# files array 10pts +# scripts.build 5pts +# +# Usage: +# audit-package-json.sh # Audit all packages +# audit-package-json.sh build-clean # Audit a single package + +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +WORKSPACE_DIR="$(dirname "$SCRIPT_DIR")" +PACKAGES_DIR="$WORKSPACE_DIR/packages" + +TOTAL_ERRORS=0 +TOTAL_WARNINGS=0 +TOTAL_REPOS=0 +REPOS_JSON="" +FIRST_REPO=1 + +REPO_SCORE=0 +REPO_MAX=0 +REPO_CHECKS="" +FIRST_CHECK=1 + +json_str() { + printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' +} + +add_check() { + check="$1" status="$2" value="$3" points_earned="$4" points_max="$5" + REPO_SCORE=$((REPO_SCORE + points_earned)) + REPO_MAX=$((REPO_MAX + points_max)) + case "$status" in + warn) TOTAL_WARNINGS=$((TOTAL_WARNINGS + 1)) ;; + error) TOTAL_ERRORS=$((TOTAL_ERRORS + 1)) ;; + esac + + entry=$(printf '{"check":"%s","status":"%s","value":"%s","points":%d,"max":%d}' \ + "$(json_str "$check")" "$status" "$(json_str "$value")" "$points_earned" "$points_max") + + if [ "$FIRST_CHECK" = "1" ]; then + REPO_CHECKS="$entry" + FIRST_CHECK=0 + else + REPO_CHECKS="${REPO_CHECKS},${entry}" + fi +} + +ok() { add_check "$1" "ok" "$2" "$3" "$3"; } +warn() { add_check "$1" "warn" "$2" 0 "$3"; } +error() { add_check "$1" "error" "$2" 0 "$3"; } + +emit_pkg() { + name="$1" pkg_path="${2:-}" + TOTAL_REPOS=$((TOTAL_REPOS + 1)) + + if [ "$REPO_MAX" -eq 0 ]; then pct=0 + else pct=$((REPO_SCORE * 100 / REPO_MAX)) + fi + + repo_json=$(printf '{"name":"%s","pkg":"%s","score":%d,"max":%d,"pct":%d,"checks":[%s]}' \ + "$(json_str "$name")" "$(json_str "$pkg_path")" "$REPO_SCORE" "$REPO_MAX" "$pct" "$REPO_CHECKS") + + if [ "$FIRST_REPO" = "1" ]; then + REPOS_JSON="$repo_json" + FIRST_REPO=0 + else + REPOS_JSON="${REPOS_JSON},${repo_json}" + fi +} + +json_get() { + node -e " + const pkg = require('$1'); + const path = '$2'.split('.'); + let val = pkg; + for (const p of path) { + if (val == null) { process.exit(0); } + val = val[p]; + } + if (val === undefined) { process.exit(0); } + if (typeof val === 'object') { console.log(JSON.stringify(val)); } + else { console.log(val); } + " 2>/dev/null +} + +check_exports_nested() { + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('$1', 'utf8')); + const exports = pkg.exports; + if (!exports) { console.log('missing'); process.exit(0); } + if (exports['.']) { console.log('nested'); } + else if (exports['import'] || exports['require'] || exports['types']) { console.log('flat'); } + else { console.log('other'); } + " 2>/dev/null +} + +check_exports_order() { + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('$1', 'utf8')); + const exports = pkg.exports; + if (!exports) { process.exit(0); } + const entry = exports['.'] || exports; + const keys = Object.keys(entry); + const importIdx = keys.indexOf('import'); + const requireIdx = keys.indexOf('require'); + if (importIdx === -1 || requireIdx === -1) { process.exit(0); } + console.log(importIdx > requireIdx ? 'require-first' : 'import-first'); + " 2>/dev/null +} + +audit_pkg() { + pkg_dir="${1%/}" + pkg_name=$(basename "$pkg_dir") + REPO_SCORE=0 + REPO_MAX=0 + REPO_CHECKS="" + FIRST_CHECK=1 + + pkg_path="$pkg_dir/package.json" + if [ ! -f "$pkg_path" ]; then + add_check "pkg" "error" "not found" 0 100 + emit_pkg "$pkg_name" + return + fi + + # private + private_val=$(json_get "$pkg_path" "private") + if [ "$private_val" = "false" ]; then ok "private" "false" 5 + elif [ -z "$private_val" ]; then warn "private" "missing" 5 + else error "private" "$private_val" 5 + fi + + # type + type_val=$(json_get "$pkg_path" "type") + if [ "$type_val" = "module" ]; then ok "type" "module" 10 + else error "type" "${type_val:-missing}" 10 + fi + + # license + license_val=$(json_get "$pkg_path" "license") + if [ "$license_val" = "MIT" ]; then ok "license" "MIT" 5 + else error "license" "${license_val:-missing}" 5 + fi + + # author + author_val=$(json_get "$pkg_path" "author") + if [ "$author_val" = "Stephen Hellicar" ]; then ok "author" "Stephen Hellicar" 5 + elif [ -z "$author_val" ]; then error "author" "missing" 5 + else warn "author" "$author_val" 5 + fi + + # description + desc_val=$(json_get "$pkg_path" "description") + if [ -n "$desc_val" ]; then ok "description" "present" 5 + else error "description" "missing" 5 + fi + + # keywords + kw_val=$(json_get "$pkg_path" "keywords") + if [ -n "$kw_val" ] && [ "$kw_val" != "[]" ]; then ok "keywords" "present" 5 + else warn "keywords" "missing or empty" 5 + fi + + # repository.url — all packages live in the same monorepo + repo_url=$(json_get "$pkg_path" "repository.url") + expected_url="git+https://github.com/shellicar/ecosystem.git" + if [ "$repo_url" = "$expected_url" ]; then ok "repository.url" "correct" 10 + elif [ -z "$repo_url" ]; then error "repository.url" "missing" 10 + else warn "repository.url" "$repo_url" 10 + fi + + # bugs.url — check it points to the ecosystem issues page + bugs_url=$(json_get "$pkg_path" "bugs.url") + case "$bugs_url" in + *github.com/shellicar/ecosystem/issues*) ok "bugs.url" "correct" 5 ;; + "") error "bugs.url" "missing" 5 ;; + *) warn "bugs.url" "$bugs_url" 5 ;; + esac + + # homepage — per-package path within the monorepo + homepage_val=$(json_get "$pkg_path" "homepage") + expected_homepage="https://github.com/shellicar/ecosystem/tree/main/packages/${pkg_name}#readme" + if [ "$homepage_val" = "$expected_homepage" ]; then ok "homepage" "correct" 5 + elif [ -z "$homepage_val" ]; then error "homepage" "missing" 5 + else warn "homepage" "$homepage_val" 5 + fi + + # publishConfig + publish_access=$(json_get "$pkg_path" "publishConfig.access") + if [ "$publish_access" = "public" ]; then ok "publishConfig" "access: public" 5 + else error "publishConfig" "missing or not public" 5 + fi + + # exports structure + exports_nested=$(check_exports_nested "$pkg_path") + if [ "$exports_nested" = "nested" ]; then ok "exports" "nested under '.'" 10 + elif [ "$exports_nested" = "flat" ]; then error "exports" "flat (should nest under '.')" 10 + elif [ "$exports_nested" = "missing" ]; then error "exports" "missing" 10 + else warn "exports" "unexpected structure" 10 + fi + + # exports condition order + exports_order=$(check_exports_order "$pkg_path") + if [ "$exports_order" = "import-first" ]; then ok "exports_order" "import before require" 5 + elif [ "$exports_order" = "require-first" ]; then warn "exports_order" "require before import" 5 + fi + + # files array + files_val=$(json_get "$pkg_path" "files") + if [ -z "$files_val" ]; then + error "files" "missing" 10 + else + has_md=$(printf '%s' "$files_val" | grep -c '\*\.md' || true) + if [ "$has_md" -gt 0 ]; then ok "files" "includes *.md" 10 + else warn "files" "missing *.md entry" 10 + fi + fi + + # scripts.build + build_script=$(json_get "$pkg_path" "scripts.build") + if [ -n "$build_script" ]; then ok "scripts.build" "$build_script" 5 + else warn "scripts.build" "missing" 5 + fi + + emit_pkg "$pkg_name" "$pkg_path" +} + +if [ $# -gt 0 ]; then + audit_pkg "$PACKAGES_DIR/$1" +else + for pkg_dir in "$PACKAGES_DIR"/*/; do + if grep -q '"private"[[:space:]]*:[[:space:]]*true' "$pkg_dir/package.json" 2>/dev/null; then + continue + fi + audit_pkg "$pkg_dir" + done +fi + +printf '{"repos":[%s],"summary":{"total":%d,"errors":%d,"warnings":%d}}\n' \ + "$REPOS_JSON" "$TOTAL_REPOS" "$TOTAL_ERRORS" "$TOTAL_WARNINGS" + +exit "$TOTAL_ERRORS" diff --git a/scripts/check-biome.sh b/scripts/check-biome.sh new file mode 100755 index 0000000..225a8ea --- /dev/null +++ b/scripts/check-biome.sh @@ -0,0 +1,154 @@ +#!/bin/sh +# Audit biome.json configuration across @shellicar/ecosystem packages. +# Outputs JSON. +# +# Checks per package (100pts total): +# biome.json present 30pts +# root: false 20pts +# extends: "//" 30pts +# linter rules overrides 20pts +# +# Usage: +# check-biome.sh # Audit all packages +# check-biome.sh build-clean # Audit a single package + +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +WORKSPACE_DIR="$(dirname "$SCRIPT_DIR")" +PACKAGES_DIR="$WORKSPACE_DIR/packages" + +TOTAL_ERRORS=0 +TOTAL_WARNINGS=0 +TOTAL_REPOS=0 +REPOS_JSON="" +FIRST_REPO=1 + +REPO_SCORE=0 +REPO_MAX=0 +REPO_CHECKS="" +FIRST_CHECK=1 + +json_str() { + printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' +} + +add_check() { + check="$1" status="$2" value="$3" points_earned="$4" points_max="$5" + REPO_SCORE=$((REPO_SCORE + points_earned)) + REPO_MAX=$((REPO_MAX + points_max)) + case "$status" in + warn) TOTAL_WARNINGS=$((TOTAL_WARNINGS + 1)) ;; + error) TOTAL_ERRORS=$((TOTAL_ERRORS + 1)) ;; + esac + + entry=$(printf '{"check":"%s","status":"%s","value":"%s","points":%d,"max":%d}' \ + "$(json_str "$check")" "$status" "$(json_str "$value")" "$points_earned" "$points_max") + + if [ "$FIRST_CHECK" = "1" ]; then + REPO_CHECKS="$entry" + FIRST_CHECK=0 + else + REPO_CHECKS="${REPO_CHECKS},${entry}" + fi +} + +ok() { add_check "$1" "ok" "$2" "$3" "$3"; } +warn() { add_check "$1" "warn" "$2" 0 "$3"; } +error() { add_check "$1" "error" "$2" 0 "$3"; } + +emit_pkg() { + name="$1" biome_path="${2:-}" + TOTAL_REPOS=$((TOTAL_REPOS + 1)) + + if [ "$REPO_MAX" -eq 0 ]; then + pct=0 + else + pct=$((REPO_SCORE * 100 / REPO_MAX)) + fi + + repo_json=$(printf '{"name":"%s","biome":"%s","score":%d,"max":%d,"pct":%d,"checks":[%s]}' \ + "$(json_str "$name")" "$(json_str "$biome_path")" "$REPO_SCORE" "$REPO_MAX" "$pct" "$REPO_CHECKS") + + if [ "$FIRST_REPO" = "1" ]; then + REPOS_JSON="$repo_json" + FIRST_REPO=0 + else + REPOS_JSON="${REPOS_JSON},${repo_json}" + fi +} + +check_pkg() { + pkg_dir="${1%/}" + pkg_name=$(basename "$pkg_dir") + REPO_SCORE=0 + REPO_MAX=0 + REPO_CHECKS="" + FIRST_CHECK=1 + + biome_file="$pkg_dir/biome.json" + if [ ! -f "$biome_file" ]; then + add_check "biome_json" "error" "not found" 0 100 + emit_pkg "$pkg_name" + return + fi + + ok "biome_json" "present" 30 + + # Extract config values in one pass + biome_vals=$(cat "$biome_file" | node -e " + let d = ''; process.stdin.on('data', c => d += c).on('end', () => { + try { + const b = JSON.parse(d); + const rules = (b.linter || {}).rules || {}; + const hasOverrides = Object.keys(rules).filter(k => k !== 'recommended').length > 0; + process.stdout.write('root=' + (b.root !== undefined ? String(b.root) : '') + '\n'); + process.stdout.write('extends=' + (b.extends || '') + '\n'); + process.stdout.write('linter_rules_overrides=' + (hasOverrides ? 'true' : 'false') + '\n'); + } catch(e) {} + }); + " 2>/dev/null || true) + + bv_get() { printf '%s' "$biome_vals" | grep "^$1=" | sed "s/^$1=//"; } + + # root: false (20pts) + root_val=$(bv_get root) + if [ "$root_val" = "false" ]; then + ok "root" "false" 20 + else + warn "root" "${root_val:-missing}" 20 + fi + + # extends: "//" (30pts) + extends_val=$(bv_get extends) + if [ "$extends_val" = "//" ]; then + ok "extends" '"//"' 30 + else + warn "extends" "${extends_val:-missing}" 30 + fi + + # linter rules overrides beyond recommended (20pts) + if [ "$(bv_get linter_rules_overrides)" = "true" ]; then + ok "linter_rules" "has overrides" 20 + else + warn "linter_rules" "only recommended" 20 + fi + + emit_pkg "$pkg_name" "$biome_file" +} + +if [ $# -gt 0 ]; then + check_pkg "$PACKAGES_DIR/$1" +else + for pkg_dir in "$PACKAGES_DIR"/*/; do + if grep -q '"private"[[:space:]]*:[[:space:]]*true' "$pkg_dir/package.json" 2>/dev/null; then + continue + fi + check_pkg "$pkg_dir" + done +fi + +printf '{"repos":[%s],"summary":{"total":%d,"errors":%d,"warnings":%d}}\n' \ + "$REPOS_JSON" "$TOTAL_REPOS" "$TOTAL_ERRORS" "$TOTAL_WARNINGS" + +exit "$TOTAL_ERRORS" diff --git a/scripts/check-tsconfig.sh b/scripts/check-tsconfig.sh new file mode 100755 index 0000000..bda9e8b --- /dev/null +++ b/scripts/check-tsconfig.sh @@ -0,0 +1,188 @@ +#!/bin/sh +# Audit resolved tsconfig settings across @shellicar/ecosystem packages. +# Uses tsc --showConfig to get the effective config, so inherited values count. +# Outputs JSON. +# +# Checks: +# verbatimModuleSyntax: true 20pts +# moduleDetection: force 15pts +# moduleResolution: bundler 25pts (node = error) +# isolatedDeclarations: true 10pts +# src/ scoped include 10pts +# tsconfig.check.json present 10pts +# +# Usage: +# check-tsconfig.sh # Audit all packages +# check-tsconfig.sh build-clean # Audit a single package + +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +WORKSPACE_DIR="$(dirname "$SCRIPT_DIR")" +PACKAGES_DIR="$WORKSPACE_DIR/packages" +TSC="$WORKSPACE_DIR/node_modules/.bin/tsc" + +TOTAL_ERRORS=0 +TOTAL_WARNINGS=0 +TOTAL_REPOS=0 +REPOS_JSON="" +FIRST_REPO=1 + +REPO_SCORE=0 +REPO_MAX=0 +REPO_CHECKS="" +FIRST_CHECK=1 + +json_str() { + printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' +} + +add_check() { + check="$1" status="$2" value="$3" points_earned="$4" points_max="$5" + REPO_SCORE=$((REPO_SCORE + points_earned)) + REPO_MAX=$((REPO_MAX + points_max)) + case "$status" in + warn) TOTAL_WARNINGS=$((TOTAL_WARNINGS + 1)) ;; + error) TOTAL_ERRORS=$((TOTAL_ERRORS + 1)) ;; + esac + + entry=$(printf '{"check":"%s","status":"%s","value":"%s","points":%d,"max":%d}' \ + "$(json_str "$check")" "$status" "$(json_str "$value")" "$points_earned" "$points_max") + + if [ "$FIRST_CHECK" = "1" ]; then + REPO_CHECKS="$entry" + FIRST_CHECK=0 + else + REPO_CHECKS="${REPO_CHECKS},${entry}" + fi +} + +ok() { add_check "$1" "ok" "$2" "$3" "$3"; } +warn() { add_check "$1" "warn" "$2" 0 "$3"; } +error() { add_check "$1" "error" "$2" 0 "$3"; } + +emit_pkg() { + name="$1" tsconfig="${2:-}" + TOTAL_REPOS=$((TOTAL_REPOS + 1)) + + if [ "$REPO_MAX" -eq 0 ]; then + pct=0 + else + pct=$((REPO_SCORE * 100 / REPO_MAX)) + fi + + repo_json=$(printf '{"name":"%s","tsconfig":"%s","score":%d,"max":%d,"pct":%d,"checks":[%s]}' \ + "$(json_str "$name")" "$(json_str "$tsconfig")" "$REPO_SCORE" "$REPO_MAX" "$pct" "$REPO_CHECKS") + + if [ "$FIRST_REPO" = "1" ]; then + REPOS_JSON="$repo_json" + FIRST_REPO=0 + else + REPOS_JSON="${REPOS_JSON},${repo_json}" + fi +} + +check_pkg() { + pkg_dir="${1%/}" + pkg_name=$(basename "$pkg_dir") + REPO_SCORE=0 + REPO_MAX=0 + REPO_CHECKS="" + FIRST_CHECK=1 + + tsconfig="$pkg_dir/tsconfig.json" + if [ ! -f "$tsconfig" ]; then + add_check "tsconfig" "error" "not found" 0 90 + emit_pkg "$pkg_name" + return + fi + + # Resolve full compiler options via tsc --showConfig + set +e + showconfig=$("$TSC" --showConfig -p "$tsconfig" 2>/dev/null) + set -e + + # Extract compiler option values in one pass + co_vals=$(printf '%s' "$showconfig" | node -e " + let d = ''; process.stdin.on('data', c => d += c).on('end', () => { + try { + const co = JSON.parse(d).compilerOptions || {}; + const get = k => co[k] !== undefined ? String(co[k]) : ''; + [ + 'verbatimModuleSyntax', + 'moduleDetection', + 'moduleResolution', + 'isolatedDeclarations', + ].forEach(k => process.stdout.write(k + '=' + get(k) + '\n')); + } catch(e) {} + }); + " 2>/dev/null || true) + + co_get() { printf '%s' "$co_vals" | grep "^$1=" | sed "s/^$1=//"; } + + # verbatimModuleSyntax (20pts) + if [ "$(co_get verbatimModuleSyntax)" = "true" ]; then + ok "verbatimModuleSyntax" "true" 20 + else + warn "verbatimModuleSyntax" "missing" 20 + fi + + # moduleDetection (15pts) + md=$(co_get moduleDetection) + if [ "$md" = "force" ]; then + ok "moduleDetection" "force" 15 + else + warn "moduleDetection" "${md:-missing}" 15 + fi + + # moduleResolution (25pts) + mr=$(co_get moduleResolution) + case "$mr" in + node*) error "moduleResolution" "$mr" 25 ;; + "") warn "moduleResolution" "missing" 25 ;; + *) ok "moduleResolution" "$mr" 25 ;; + esac + + # isolatedDeclarations (10pts) + if [ "$(co_get isolatedDeclarations)" = "true" ]; then + ok "isolatedDeclarations" "true" 10 + else + warn "isolatedDeclarations" "missing" 10 + fi + + # src/ scoped include (10pts) + if grep -q '"include"' "$tsconfig" 2>/dev/null; then + if grep '"include"' "$tsconfig" | grep -q '"src/'; then + ok "src_include" "src/ scoped" 10 + else + warn "src_include" "not src/ scoped" 10 + fi + else + warn "src_include" "missing" 10 + fi + + # tsconfig.check.json present (10pts) + if [ -f "$pkg_dir/tsconfig.check.json" ]; then + ok "check_json" "present" 10 + else + warn "check_json" "missing" 10 + fi + + emit_pkg "$pkg_name" "$tsconfig" +} + +if [ $# -gt 0 ]; then + check_pkg "$PACKAGES_DIR/$1" +else + for pkg_dir in "$PACKAGES_DIR"/*/; do + if grep -q '"private"[[:space:]]*:[[:space:]]*true' "$pkg_dir/package.json" 2>/dev/null; then + continue + fi + check_pkg "$pkg_dir" + done +fi + +printf '{"repos":[%s],"summary":{"total":%d,"errors":%d,"warnings":%d}}\n' \ + "$REPOS_JSON" "$TOTAL_REPOS" "$TOTAL_ERRORS" "$TOTAL_WARNINGS" + +exit "$TOTAL_ERRORS" diff --git a/scripts/health.sh b/scripts/health.sh new file mode 100755 index 0000000..c0f6efd --- /dev/null +++ b/scripts/health.sh @@ -0,0 +1,90 @@ +#!/bin/sh +# Ecosystem health dashboard — aggregates all audit scripts. +# Per-package: check-tsconfig, audit-package-json, check-biome. +# Workspace-level: check-deps. +# Outputs JSON. +# +# Usage: +# health.sh # Full audit +# health.sh build-clean # Single-package audit (no deps check) + +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PKG_ARG="${1:-}" + +AUDIT_TMP="$(mktemp -d)" +trap 'rm -rf "$AUDIT_TMP"' EXIT + +run_check() { + script="$1" out="$2" + if [ -n "$PKG_ARG" ]; then + "$SCRIPT_DIR/$script" "$PKG_ARG" > "$AUDIT_TMP/$out" 2>/dev/null || true + else + "$SCRIPT_DIR/$script" > "$AUDIT_TMP/$out" 2>/dev/null || true + fi +} + +run_check "check-tsconfig.sh" "tsconfig.json" +run_check "audit-package-json.sh" "pkg.json" +run_check "check-biome.sh" "biome.json" + +if [ -z "$PKG_ARG" ]; then + "$SCRIPT_DIR/check-deps.sh" > "$AUDIT_TMP/deps.json" 2>/dev/null || true +fi + +AUDIT_TMP="$AUDIT_TMP" node -e " + const fs = require('fs'); + const d = process.env.AUDIT_TMP; + + const readJson = name => { + const p = d + '/' + name; + if (!fs.existsSync(p)) return null; + try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch(e) { return null; } + }; + + const tsconfig = readJson('tsconfig.json'); + const pkgJson = readJson('pkg.json'); + const biome = readJson('biome.json'); + const deps = readJson('deps.json'); + + const byName = new Map(); + + const mergeRepos = (data, key) => { + if (!data || !data.repos) return; + for (const repo of data.repos) { + if (!byName.has(repo.name)) byName.set(repo.name, { checks: {} }); + byName.get(repo.name).checks[key] = { score: repo.score, max: repo.max, pct: repo.pct }; + } + }; + + mergeRepos(tsconfig, 'tsconfig'); + mergeRepos(pkgJson, 'package_json'); + mergeRepos(biome, 'biome'); + + const packages = Array.from(byName.entries()).map(([name, data]) => { + const vals = Object.values(data.checks); + const score = vals.reduce((a, c) => a + c.score, 0); + const max = vals.reduce((a, c) => a + c.max, 0); + const pct = max > 0 ? Math.round(score * 100 / max) : 0; + return { name, score, max, pct, checks: data.checks }; + }); + + const avgPct = packages.length > 0 + ? Math.round(packages.reduce((a, p) => a + p.pct, 0) / packages.length) + : 0; + + const errors = [tsconfig, pkgJson, biome].filter(Boolean) + .reduce((a, x) => a + (x.summary ? x.summary.errors : 0), 0); + const warnings = [tsconfig, pkgJson, biome].filter(Boolean) + .reduce((a, x) => a + (x.summary ? x.summary.warnings : 0), 0); + + const result = { + packages, + summary: { packages: packages.length, avg_pct: avgPct, errors, warnings }, + }; + + if (deps) result.workspace = { deps }; + + process.stdout.write(JSON.stringify(result) + '\n'); +" From b526782b2c22f52b4a758fe24b6505e8ca728e36 Mon Sep 17 00:00:00 2001 From: Stephen Hellicar Date: Sun, 12 Apr 2026 10:23:08 +1000 Subject: [PATCH 4/6] Add vulnerability severity counts to health dashboard pnpm audit --json runs at workspace root alongside check-deps. The severity breakdown (critical/high/moderate/low/total) lands in workspace.vuln in the output, parallel to workspace.deps. Single-package mode is unaffected -- both workspace checks are skipped when a package argument is given. --- scripts/health.sh | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/scripts/health.sh b/scripts/health.sh index c0f6efd..d3f35cf 100755 --- a/scripts/health.sh +++ b/scripts/health.sh @@ -1,7 +1,7 @@ #!/bin/sh # Ecosystem health dashboard — aggregates all audit scripts. # Per-package: check-tsconfig, audit-package-json, check-biome. -# Workspace-level: check-deps. +# Workspace-level: check-deps, pnpm audit. # Outputs JSON. # # Usage: @@ -11,6 +11,7 @@ set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +WORKSPACE_DIR="$(dirname "$SCRIPT_DIR")" PKG_ARG="${1:-}" AUDIT_TMP="$(mktemp -d)" @@ -31,6 +32,7 @@ run_check "check-biome.sh" "biome.json" if [ -z "$PKG_ARG" ]; then "$SCRIPT_DIR/check-deps.sh" > "$AUDIT_TMP/deps.json" 2>/dev/null || true + pnpm --dir "$WORKSPACE_DIR" audit --json > "$AUDIT_TMP/vuln.json" 2>/dev/null || true fi AUDIT_TMP="$AUDIT_TMP" node -e " @@ -47,6 +49,7 @@ AUDIT_TMP="$AUDIT_TMP" node -e " const pkgJson = readJson('pkg.json'); const biome = readJson('biome.json'); const deps = readJson('deps.json'); + const vuln = readJson('vuln.json'); const byName = new Map(); @@ -84,7 +87,20 @@ AUDIT_TMP="$AUDIT_TMP" node -e " summary: { packages: packages.length, avg_pct: avgPct, errors, warnings }, }; - if (deps) result.workspace = { deps }; + if (deps || vuln) { + result.workspace = {}; + if (deps) result.workspace.deps = deps; + if (vuln) { + const v = (vuln.metadata || {}).vulnerabilities || {}; + result.workspace.vuln = { + critical: v.critical || 0, + high: v.high || 0, + moderate: v.moderate || 0, + low: v.low || 0, + total: v.total || 0, + }; + } + } process.stdout.write(JSON.stringify(result) + '\n'); " From 900a4096adf6928b59c67e5f3c77e5f70fa39993 Mon Sep 17 00:00:00 2001 From: Stephen Hellicar Date: Sun, 12 Apr 2026 11:13:25 +1000 Subject: [PATCH 5/6] Fix lockfile. --- pnpm-lock.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 119f559..edeab0d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1348,9 +1348,15 @@ importers: ajv: specifier: ^8 version: 8.18.0 + semver: + specifier: ^7.7.4 + version: 7.7.4 tsx: specifier: ^4 version: 4.21.0 + yaml: + specifier: ^2.8.3 + version: 2.8.3 zod: specifier: ^4 version: 4.3.6 From c40e656253fcfd596ae9e2e35c6a06b26e0540c4 Mon Sep 17 00:00:00 2001 From: Stephen Hellicar Date: Sun, 12 Apr 2026 11:42:22 +1000 Subject: [PATCH 6/6] Linting. --- scripts/fix-ghsa.mjs | 48 ++++++++++++++++++++++---------------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/scripts/fix-ghsa.mjs b/scripts/fix-ghsa.mjs index f5c963e..40d197e 100755 --- a/scripts/fix-ghsa.mjs +++ b/scripts/fix-ghsa.mjs @@ -12,36 +12,36 @@ // Usage: // echo '[...]' | node scripts/fix-ghsa.mjs -import { readFileSync, writeFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import semver from "semver"; -import YAML from "yaml"; +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import semver from 'semver'; +import YAML from 'yaml'; const __dirname = dirname(fileURLToPath(import.meta.url)); -const workspacePath = join(__dirname, "..", "pnpm-workspace.yaml"); +const workspacePath = join(__dirname, '..', 'pnpm-workspace.yaml'); // ── Read inputs ───────────────────────────────────────────────────── -const input = readFileSync("/dev/stdin", "utf8"); +const input = readFileSync('/dev/stdin', 'utf8'); const vulnerabilities = JSON.parse(input); if (!Array.isArray(vulnerabilities) || vulnerabilities.length === 0) { - process.stderr.write("No vulnerability data on stdin\n"); + process.stderr.write('No vulnerability data on stdin\n'); process.exit(1); } -const raw = readFileSync(workspacePath, "utf8"); -const doc = YAML.parseDocument(raw, { schema: "failsafe" }); -const overridesNode = doc.get("overrides", true); +const raw = readFileSync(workspacePath, 'utf8'); +const doc = YAML.parseDocument(raw, { schema: 'failsafe' }); +const overridesNode = doc.get('overrides', true); const merged = new Map(); -if (overridesNode && YAML.isPair(overridesNode) || YAML.isMap(overridesNode)) { +if ((overridesNode && YAML.isPair(overridesNode)) || YAML.isMap(overridesNode)) { for (const pair of overridesNode.items) { merged.set(String(pair.key), String(pair.value)); } } else if (overridesNode) { // Fallback: plain object parse - const parsed = YAML.parse(raw, { schema: "failsafe" }); + const parsed = YAML.parse(raw, { schema: 'failsafe' }); if (parsed.overrides) { for (const [k, v] of Object.entries(parsed.overrides)) { merged.set(k, v); @@ -55,20 +55,20 @@ const actions = []; // Convert API range ">= 3.0.0, < 3.1.2" to pnpm format ">=3.0.0 <3.1.2" function normalizeRange(range) { return range - .replace(/,\s*/g, " ") - .replace(/(>=|<=|>|<)\s+/g, "$1") + .replace(/,\s*/g, ' ') + .replace(/(>=|<=|>|<)\s+/g, '$1') .trim(); } // Parse override key "koa@>=3.0.0 <3.1.2" -> { pkg: "koa", range: ">=3.0.0 <3.1.2" } function parseKey(key) { let atIdx; - if (key.startsWith("@")) { - atIdx = key.indexOf("@", 1); + if (key.startsWith('@')) { + atIdx = key.indexOf('@', 1); } else { - atIdx = key.indexOf("@"); + atIdx = key.indexOf('@'); } - if (atIdx === -1) return { pkg: key, range: "" }; + if (atIdx === -1) return { pkg: key, range: '' }; return { pkg: key.substring(0, atIdx), range: key.substring(atIdx + 1) }; } @@ -127,14 +127,14 @@ for (const vuln of vulnerabilities) { if (existing.pkg !== vuln.pkg) continue; if (existingKey === key) continue; // handled separately below if (rangeContains(range, existing.range)) { - actions.push({ action: "remove", key: existingKey, value: merged.get(existingKey) }); + actions.push({ action: 'remove', key: existingKey, value: merged.get(existingKey) }); merged.delete(existingKey); } } // Add or update the override merged.set(key, value); - actions.push({ action: "add", key, value }); + actions.push({ action: 'add', key, value }); } // ── Write back ────────────────────────────────────────────────────── @@ -150,13 +150,13 @@ for (const [k, v] of sortedEntries) { newMap.add(new YAML.Pair(keyNode, valNode)); } -doc.set("overrides", newMap); +doc.set('overrides', newMap); writeFileSync(workspacePath, doc.toString()); // ── Report ────────────────────────────────────────────────────────── for (const a of actions) { - if (a.action === "remove") { + if (a.action === 'remove') { process.stdout.write(` REMOVE ${a.key}: '${a.value}'\n`); } else { process.stdout.write(` ADD ${a.key}: '${a.value}'\n`); @@ -164,7 +164,7 @@ for (const a of actions) { } if (actions.length === 0) { - process.stdout.write(" No changes needed\n"); + process.stdout.write(' No changes needed\n'); process.exit(2); }