← Files NightshiftARCHIVED FILE
runtime/inventory.sh
16.4 KB · Oct 4, 2026 · 12:30 UTC
#!/usr/bin/env bash
# inventory.sh — what the work target declares, one table per package.
#
# inventory.sh --project <work-target> [--json]
#
# Walks the work target — `git ls-files` when it is a repository, so .gitignore is
# honoured, otherwise `find` with the usual build and vendor directories pruned —
# and reports one package per manifest it finds: package.json, Cargo.toml, go.mod,
# pyproject.toml, setup.cfg or requirements.txt. Monorepos fall out of that walk,
# and a workspace declaration (npm/pnpm/yarn `workspaces`, `pnpm-workspace.yaml`,
# a Cargo `[workspace]`) is reported as its own field.
#
# Per package: the package manager and the lockfile behind it, the scripts declared
# for test, lint, typecheck, build and format, the config files present, and each
# named tool as `runnable` (a bin under node_modules/.bin or the tool on PATH),
# `declared` (the package names it, no bin found) or `absent`. Those three words are
# the whole verdict — the report never says a project is set up wrongly.
#
# Read-only. Writes nothing, caches nothing, installs nothing, asks nothing.
# Scripts come from package.json; other manifests report `-` for them.
#
# Exit: 0 inventory · 1 usage · 3 unavailable
set -u
_here="${BASH_SOURCE[0]%/*}"; [ "$_here" != "${BASH_SOURCE[0]}" ] || _here=.
# shellcheck source=plugins/nightshift/lib/lib.sh
. "$_here/../lib/lib.sh"
PROJECT="${CLAUDE_PROJECT_DIR:-${CODEX_PROJECT_DIR:-$PWD}}"
MODE=md
while [ $# -gt 0 ]; do
case "$1" in
--project)
[ $# -ge 2 ] || { printf 'inventory: --project needs a value\n' >&2; exit 1; }
PROJECT="$2"
shift 2
;;
--json)
MODE=json
shift
;;
-h | --help)
awk 'NR == 1 { next } !/^#/ { exit } { sub(/^# ?/, ""); print }' "$0"
exit 1
;;
*) printf 'inventory: unknown argument: %s\n' "$1" >&2; exit 1 ;;
esac
done
unavail() {
printf 'unavailable inventory: %s\n' "$1"
exit 3
}
TARGET="$(cd -P "$PROJECT" 2>/dev/null && pwd)" ||
unavail 'the work target is not a readable directory'
TMPD=""
# shellcheck disable=SC2317,SC2329 # trap EXIT invokes this
_cleanup() { [ -z "$TMPD" ] || rm -rf -- "$TMPD"; }
trap _cleanup EXIT
TMPD="$(mktemp -d "${TMPDIR:-/tmp}/nightshift-inventory.XXXXXX")" ||
unavail 'no writable temporary directory'
# The fixed vocabularies. Every package reports every row, so two inventories of the
# same tree line up column for column even when a package carries none of them.
ROLES="build format lint test typecheck"
TOOLS="biome clippy eslint golangci-lint mypy prettier pytest ruff tsc"
MANIFESTS="package.json Cargo.toml go.mod pyproject.toml setup.cfg requirements.txt"
CI_FILES=".gitlab-ci.yml azure-pipelines.yml Jenkinsfile .travis.yml
bitbucket-pipelines.yml .circleci/config.yml"
# ------------------------------------------------------------------ the walk
VCS=none
if [ "$(git -C "$TARGET" rev-parse --is-inside-work-tree 2>/dev/null)" = true ]; then
VCS=git
fi
# A path no inventory should describe: an installed dependency, a build output, or a
# cache. Pruned in both walks, so a repository that tracks its vendor tree reads the
# same as one that ignores it.
_noise() {
case "/$1/" in
*/node_modules/* | */.git/* | */.venv/* | */venv/* | */__pycache__/* | */vendor/* \
| */dist/* | */build/* | */.next/* | */.tox/* | */target/debug/* | */target/release/*)
return 0
;;
esac
return 1
}
if [ "$VCS" = git ]; then
git -C "$TARGET" ls-files --cached --others --exclude-standard >"$TMPD/all" 2>/dev/null ||
: >"$TMPD/all"
else
(cd "$TARGET" && find . \
\( -name node_modules -o -name .git -o -name .venv -o -name venv \
-o -name __pycache__ -o -name vendor -o -name dist -o -name build \
-o -name .next -o -name .tox \) -prune -o -type f -print) >"$TMPD/all" 2>/dev/null ||
: >"$TMPD/all"
sed -e 's#^\./##' "$TMPD/all" >"$TMPD/all.clean"
mv "$TMPD/all.clean" "$TMPD/all"
fi
# Package directories: one per manifest, relative to the work target, root as ".".
: >"$TMPD/dirs"
while IFS= read -r rel; do
[ -n "$rel" ] || continue
_noise "$rel" && continue
base="${rel##*/}"
for m in $MANIFESTS; do
[ "$base" = "$m" ] || continue
dir="${rel%/*}"
[ "$dir" != "$rel" ] || dir=.
printf '%s\n' "$dir" >>"$TMPD/dirs"
break
done
done <"$TMPD/all"
LC_ALL=C sort -u "$TMPD/dirs" >"$TMPD/pkgdirs"
# CI definitions the work target carries, by name and by workflow directory.
: >"$TMPD/ci"
while IFS= read -r rel; do
[ -n "$rel" ] || continue
_noise "$rel" && continue
case "$rel" in
.github/workflows/*.yml | .github/workflows/*.yaml)
printf '%s\n' "$rel" >>"$TMPD/ci"
continue
;;
esac
for c in $CI_FILES; do
[ "$rel" = "$c" ] && printf '%s\n' "$rel" >>"$TMPD/ci"
done
done <"$TMPD/all"
LC_ALL=C sort -u "$TMPD/ci" >"$TMPD/cifiles"
# ------------------------------------------------------------------ per package
# _first DIR NAME... -> FOUND, the first of NAME that exists under DIR, else "-".
_first() {
local dir="$1" name
shift
for name in "$@"; do
if [ -f "$TARGET/$dir/$name" ]; then
FOUND="$name"
return 0
fi
done
FOUND=-
return 0
}
# _section DIR FILE PATTERN -> 0 when FILE under DIR opens that section.
_section() {
[ -f "$TARGET/$1/$2" ] || return 1
LC_ALL=C grep -q "$3" "$TARGET/$1/$2"
}
# The token has to stand alone: a dependency on `ruff-lsp` is not a dependency on
# `ruff`, so a neighbouring name character rules the hit out and only a delimiter — an
# end of line, a version operator, an extras bracket, a quote or a comma — lets it
# through. Status 0 when the file names the token that way.
# shellcheck disable=SC2016 # awk program; $0 is an awk field
AWK_MENTIONS='
function edge(c) { return (c == "") ? 1 : (index(DELIM, c) > 0) }
BEGIN { DELIM = " \t\r=<>[~!\"," sprintf("%c", 39); n = length(tok); ok = 0 }
{
s = $0
p = 0
while (1) {
i = index(substr(s, p + 1), tok)
if (i == 0) break
i = p + i
if (edge(i == 1 ? "" : substr(s, i - 1, 1)) && edge(substr(s, i + n, 1))) {
ok = 1
exit
}
p = i
}
}
END { exit ok ? 0 : 1 }
'
# _mentions DIR NAME -> 0 when a manifest in DIR names NAME as a package of its own.
_mentions() {
local dir="$1" mention="$2" f
for f in package.json Cargo.toml go.mod pyproject.toml setup.cfg requirements.txt tox.ini; do
[ -f "$TARGET/$dir/$f" ] || continue
LC_ALL=C awk -v tok="$mention" "$AWK_MENTIONS" "$TARGET/$dir/$f" && return 0
done
return 1
}
# _bin DIR NAME -> 0 when NAME is reachable: a bin file under this package's
# node_modules/.bin or any ancestor's up to the work target, or NAME on PATH.
_bin() {
local dir="$1" name="$2" probe
probe="$dir"
while :; do
if [ "$probe" = . ]; then
[ -f "$TARGET/node_modules/.bin/$name" ] && return 0
break
fi
[ -f "$TARGET/$probe/node_modules/.bin/$name" ] && return 0
case "$probe" in
*/*) probe="${probe%/*}" ;;
*) probe=. ;;
esac
done
command -v "$name" >/dev/null 2>&1
}
# _lockfile DIR -> LOCK relative to the work target and MANAGER, taking the nearest
# ancestor's lockfile when the package has none of its own — which is what a
# workspace looks like from inside one of its packages.
_lockfile() {
local dir="$1" probe pick
probe="$dir"
while :; do
for pick in pnpm-lock.yaml yarn.lock package-lock.json npm-shrinkwrap.json bun.lockb \
Cargo.lock go.sum uv.lock poetry.lock Pipfile.lock; do
if [ "$probe" = . ]; then
[ -f "$TARGET/$pick" ] || continue
LOCK="$pick"
else
[ -f "$TARGET/$probe/$pick" ] || continue
LOCK="$probe/$pick"
fi
case "$pick" in
pnpm-lock.yaml) MANAGER=pnpm ;;
yarn.lock) MANAGER=yarn ;;
package-lock.json | npm-shrinkwrap.json) MANAGER=npm ;;
bun.lockb) MANAGER=bun ;;
Cargo.lock) MANAGER=cargo ;;
go.sum) MANAGER=go ;;
uv.lock) MANAGER=uv ;;
poetry.lock) MANAGER=poetry ;;
Pipfile.lock) MANAGER=pipenv ;;
esac
return 0
done
[ "$probe" != . ] || break
case "$probe" in
*/*) probe="${probe%/*}" ;;
*) probe=. ;;
esac
done
LOCK=-
MANAGER=-
return 0
}
# One script name per line, because a name may hold spaces: `npm run "lint all"` is
# one script, and a list joined by spaces would report it as two that do not exist.
JQ_PACKAGE='
def cl: gsub("[[:cntrl:]]"; " ");
( ["workspaces\t" + (if (.workspaces == null) then "no" else "yes" end)]
+ ((.scripts // {}) | keys | map("script-key\t" + (. | cl)))
) | .[]
'
STREAM="$TMPD/stream"
: >"$STREAM"
printf 'target\t%s\n' "$TARGET" >>"$STREAM"
printf 'vcs\t%s\n' "$VCS" >>"$STREAM"
while IFS= read -r rel; do
[ -n "$rel" ] || continue
printf 'ci\t%s\n' "$rel" >>"$STREAM"
done <"$TMPD/cifiles"
while IFS= read -r dir; do
[ -n "$dir" ] || continue
KIND=other
[ -f "$TARGET/$dir/requirements.txt" ] && KIND=python
[ -f "$TARGET/$dir/setup.cfg" ] && KIND=python
[ -f "$TARGET/$dir/pyproject.toml" ] && KIND=python
[ -f "$TARGET/$dir/go.mod" ] && KIND=go
[ -f "$TARGET/$dir/Cargo.toml" ] && KIND=cargo
[ -f "$TARGET/$dir/package.json" ] && KIND=node
: >"$TMPD/scriptkeys"
WORKSPACES=-
if [ -f "$TARGET/$dir/package.json" ]; then
command -v jq >/dev/null 2>&1 ||
unavail 'jq is required to read package.json and is not on PATH'
jq -r "$JQ_PACKAGE" <"$TARGET/$dir/package.json" >"$TMPD/pkg" 2>/dev/null ||
unavail "$dir/package.json is not readable JSON"
LC_ALL=C awk -F'\t' '$1 == "script-key" { print $2 }' <"$TMPD/pkg" >"$TMPD/scriptkeys"
WORKSPACES="$(LC_ALL=C awk -F'\t' '$1 == "workspaces" { print $2; exit }' <"$TMPD/pkg")"
if [ "$WORKSPACES" = no ] && [ -f "$TARGET/$dir/pnpm-workspace.yaml" ]; then
WORKSPACES=yes
fi
elif [ -f "$TARGET/$dir/Cargo.toml" ]; then
if _section "$dir" Cargo.toml '^\[workspace\]'; then WORKSPACES=yes; else WORKSPACES=no; fi
fi
_lockfile "$dir"
if [ "$MANAGER" = - ]; then
case "$KIND" in
go) MANAGER=go ;;
cargo) MANAGER=cargo ;;
python) MANAGER=pip ;;
esac
fi
{
printf 'pkg\t%s\t%s\n' "$dir" "$KIND"
printf 'field\t%s\tmanager\t%s\n' "$dir" "$MANAGER"
printf 'field\t%s\tlockfile\t%s\n' "$dir" "$LOCK"
printf 'field\t%s\tworkspaces\t%s\n' "$dir" "$WORKSPACES"
} >>"$STREAM"
for role in $ROLES; do
case "$role" in
typecheck) candidates="typecheck type-check types tsc" ;;
format) candidates="format fmt" ;;
*) candidates="$role" ;;
esac
value=-
for candidate in $candidates; do
while IFS= read -r key; do
if [ "$key" = "$candidate" ]; then
value="$candidate"
break
fi
done <"$TMPD/scriptkeys"
[ "$value" = - ] || break
done
printf 'script\t%s\t%s\t%s\n' "$dir" "$role" "$value" >>"$STREAM"
done
_config() { printf 'config\t%s\t%s\t%s\n' "$dir" "$1" "$2" >>"$STREAM"; }
_first "$dir" biome.json biome.jsonc
CFG_BIOME="$FOUND"
_config biome "$CFG_BIOME"
_first "$dir" clippy.toml .clippy.toml
CFG_CLIPPY="$FOUND"
_config clippy "$CFG_CLIPPY"
_first "$dir" .editorconfig
CFG_EDITORCONFIG="$FOUND"
_config editorconfig "$CFG_EDITORCONFIG"
_first "$dir" eslint.config.js eslint.config.mjs eslint.config.cjs eslint.config.ts \
.eslintrc .eslintrc.js .eslintrc.cjs .eslintrc.json .eslintrc.yml .eslintrc.yaml
CFG_ESLINT="$FOUND"
_config eslint "$CFG_ESLINT"
_first "$dir" .golangci.yml .golangci.yaml .golangci.toml .golangci.json
CFG_GOLANGCI="$FOUND"
_config golangci "$CFG_GOLANGCI"
_first "$dir" mypy.ini .mypy.ini
if [ "$FOUND" = - ] && _section "$dir" pyproject.toml '^\[tool\.mypy'; then
FOUND=pyproject.toml
fi
if [ "$FOUND" = - ] && _section "$dir" setup.cfg '^\[mypy\]'; then
FOUND=setup.cfg
fi
CFG_MYPY="$FOUND"
_config mypy "$CFG_MYPY"
_first "$dir" .prettierrc .prettierrc.json .prettierrc.yml .prettierrc.yaml \
.prettierrc.js .prettierrc.cjs prettier.config.js prettier.config.cjs prettier.config.mjs
CFG_PRETTIER="$FOUND"
_config prettier "$CFG_PRETTIER"
_first "$dir" pytest.ini
if [ "$FOUND" = - ] && _section "$dir" pyproject.toml '^\[tool\.pytest'; then
FOUND=pyproject.toml
fi
if [ "$FOUND" = - ] && _section "$dir" setup.cfg '^\[tool:pytest\]'; then
FOUND=setup.cfg
fi
if [ "$FOUND" = - ] && _section "$dir" tox.ini '^\[pytest\]'; then
FOUND=tox.ini
fi
CFG_PYTEST="$FOUND"
_config pytest "$CFG_PYTEST"
_first "$dir" ruff.toml .ruff.toml
if [ "$FOUND" = - ] && _section "$dir" pyproject.toml '^\[tool\.ruff'; then
FOUND=pyproject.toml
fi
CFG_RUFF="$FOUND"
_config ruff "$CFG_RUFF"
_first "$dir" tsconfig.json
CFG_TSCONFIG="$FOUND"
_config tsconfig "$CFG_TSCONFIG"
for name in $TOOLS; do
case "$name" in
biome) cfg="$CFG_BIOME"; bin=biome; mention=biome ;;
clippy) cfg="$CFG_CLIPPY"; bin=cargo-clippy; mention=clippy ;;
eslint) cfg="$CFG_ESLINT"; bin=eslint; mention=eslint ;;
golangci-lint) cfg="$CFG_GOLANGCI"; bin=golangci-lint; mention=golangci-lint ;;
mypy) cfg="$CFG_MYPY"; bin=mypy; mention=mypy ;;
prettier) cfg="$CFG_PRETTIER"; bin=prettier; mention=prettier ;;
pytest) cfg="$CFG_PYTEST"; bin=pytest; mention=pytest ;;
ruff) cfg="$CFG_RUFF"; bin=ruff; mention=ruff ;;
tsc) cfg="$CFG_TSCONFIG"; bin=tsc; mention=typescript ;;
esac
if _bin "$dir" "$bin"; then
state=runnable
elif [ "$cfg" != - ] || _mentions "$dir" "$mention"; then
state=declared
else
state=absent
fi
printf 'tool\t%s\t%s\t%s\n' "$dir" "$name" "$state" >>"$STREAM"
done
done <"$TMPD/pkgdirs"
# ------------------------------------------------------------------ rendering
# shellcheck disable=SC2016 # awk program; $1..$4 are awk fields
AWK_RENDER='
function san(s) {
gsub(/[^ -~]/, " ", s)
gsub(/ +/, " ", s)
sub(/^ +/, "", s)
sub(/ +$/, "", s)
if (s == "") s = "-"
return s
}
function jesc(s, i, c, o) {
o = ""
for (i = 1; i <= length(s); i++) {
c = substr(s, i, 1)
if (c == "\"") o = o "\\\""
else if (c == "\\") o = o "\\\\"
else o = o c
}
return o
}
function cell(s, i, c, o) {
o = ""
for (i = 1; i <= length(s); i++) {
c = substr(s, i, 1)
if (c == "|") o = o "\\|"
else o = o c
}
return o
}
BEGIN {
FS = "\t"
target = "-"; vcs = "none"; nci = 0; npkg = 0; nrow = 0
}
$1 == "target" { target = san($2); next }
$1 == "vcs" { vcs = san($2); next }
$1 == "ci" { nci++; ci[nci] = san($2); next }
$1 == "pkg" { npkg++; pkg[npkg] = san($2); kind[npkg] = san($3); next }
$1 == "field" || $1 == "script" || $1 == "config" || $1 == "tool" {
nrow++
rk[nrow] = $1; rp[nrow] = san($2); rn[nrow] = san($3); rv[nrow] = san($4)
next
}
END {
if (MODE == "json") {
printf "{\"ci\":["
for (i = 1; i <= nci; i++) printf "%s\"%s\"", (i > 1 ? "," : ""), jesc(ci[i])
printf "],\"packages\":["
for (p = 1; p <= npkg; p++) {
printf "%s{", (p > 1 ? "," : "")
group(p, "config", "configs")
printf ",\"kind\":\"%s\"", jesc(kind[p])
printf ",\"lockfile\":\"%s\"", jesc(one(p, "lockfile"))
printf ",\"manager\":\"%s\"", jesc(one(p, "manager"))
printf ",\"path\":\"%s\",", jesc(pkg[p])
group(p, "script", "scripts")
printf ","
group(p, "tool", "tools")
printf ",\"workspaces\":\"%s\"}", jesc(one(p, "workspaces"))
}
printf "],\"target\":\"%s\",\"vcs\":\"%s\",\"version\":1}\n", jesc(target), jesc(vcs)
exit
}
printf "inventory: %d %s in %s (%s)\n", npkg, (npkg == 1 ? "package" : "packages"), target, vcs
printf "ci:"
if (nci == 0) printf " none"
for (i = 1; i <= nci; i++) printf " %s", ci[i]
printf "\n"
for (p = 1; p <= npkg; p++) {
printf "\n## %s (%s)\n\n", pkg[p], kind[p]
print "| field | value |"
print "| --- | --- |"
for (i = 1; i <= nrow; i++) {
if (rp[i] != pkg[p]) continue
label = rn[i]
if (rk[i] == "script") label = "script " rn[i]
else if (rk[i] == "config") label = "config " rn[i]
else if (rk[i] == "tool") label = "tool " rn[i]
printf "| %s | %s |\n", cell(label), cell(rv[i])
}
}
}
function one(p, name, i) {
for (i = 1; i <= nrow; i++) {
if (rk[i] == "field" && rp[i] == pkg[p] && rn[i] == name) return rv[i]
}
return "-"
}
function group(p, kindName, label, i, first) {
printf "\"%s\":{", label
first = 1
for (i = 1; i <= nrow; i++) {
if (rk[i] != kindName || rp[i] != pkg[p]) continue
printf "%s\"%s\":\"%s\"", (first ? "" : ","), jesc(rn[i]), jesc(rv[i])
first = 0
}
printf "}"
}
'
LC_ALL=C awk -v MODE="$MODE" "$AWK_RENDER" "$STREAM"
exit 0
SHA-256: 20ea70494da9e287bdb3f3b20c1324a463883cf1cb98a76b5df6bc36a5333fdc