chore(migration): standalone SC2/SC7 validation harness
This commit is contained in:
parent
45322d297a
commit
1769657a91
2 changed files with 182 additions and 0 deletions
|
|
@ -0,0 +1,130 @@
|
|||
#!/usr/bin/env bash
|
||||
# Step 6 — Standalone validation harness (SC2 + SC7).
|
||||
# For each target: ensures the extract is prepped (extract -> rehome -> fix-references, all idempotent),
|
||||
# copies it to a clean room /tmp/claude-<key> (no marketplace parent), then:
|
||||
# SC2: runs the runner the plugin itself declares in plugin-map.json (test_cmd) — always the glob form
|
||||
# `node --test 'tests/**/*.test.mjs'` or `node --test <dir>/*.test.mjs`, NEVER a bare dir (Node 25 gotcha),
|
||||
# or `bash tests/validate-plugin.sh` / `bash validate-plugin.generic.sh <key>` for the test-less plugins.
|
||||
# linkedin-studio is two-tier (M11): the .mjs core is the HARD gate; its TS analytics suite is
|
||||
# reported as ADVISORY (npm/network — not a clean-room gate).
|
||||
# SC7: no tracked STATE.md / *.local.md (except okr/templates/okr.local.md.template), no ../../README.md,
|
||||
# .gitignore ignores .claude/.
|
||||
# voyage: .forgejo/ISSUE_TEMPLATE/ survives the extraction and holds no monorepo-relative path (M17).
|
||||
# Emits a per-repo PASS/FAIL table; exits non-zero if any target FAILs (escalate — never mask). NULL push (D8).
|
||||
#
|
||||
# Usage: 40-validate-standalone.sh <target-key>
|
||||
# 40-validate-standalone.sh --all
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
MAP="$SCRIPT_DIR/plugin-map.json"
|
||||
GENERIC_VALIDATOR="$SCRIPT_DIR/templates/validate-plugin.generic.sh"
|
||||
WORK="${WORK:-/tmp/polyrepo-migration}"
|
||||
|
||||
[ -f "$MAP" ] || { echo "plugin-map.json missing at $MAP" >&2; exit 1; }
|
||||
|
||||
ARG="${1:-}"
|
||||
[ -n "$ARG" ] || { echo "usage: 40-validate-standalone.sh <target-key>|--all" >&2; exit 2; }
|
||||
|
||||
if [ "$ARG" = "--all" ]; then
|
||||
KEYS="$(python3 -c "import json; print('\n'.join(sorted(json.load(open('$MAP'))['targets'])))")"
|
||||
else
|
||||
KEYS="$ARG"
|
||||
fi
|
||||
|
||||
FAILS=0
|
||||
|
||||
prep_target() {
|
||||
local key="$1"
|
||||
local dest="$WORK/$key"
|
||||
if [ ! -d "$dest/.git" ]; then
|
||||
WORK="$WORK" bash "$SCRIPT_DIR/10-extract.sh" "$key" >/dev/null || return 1
|
||||
fi
|
||||
WORK="$WORK" bash "$SCRIPT_DIR/20-rehome-config.sh" "$key" >/dev/null || return 1
|
||||
WORK="$WORK" node "$SCRIPT_DIR/30-fix-references.mjs" "$key" >/dev/null || return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
validate_target() {
|
||||
local key="$1"
|
||||
local dest="$WORK/$key"
|
||||
local cr="/tmp/claude-$key"
|
||||
local problems=""
|
||||
|
||||
rm -rf "$cr"
|
||||
cp -R "$dest" "$cr"
|
||||
|
||||
# --- SC7: no tracked STATE.md / *.local.md (except the okr template) ---
|
||||
local leaks
|
||||
leaks="$(git -C "$cr" ls-files | grep -E 'STATE\.md|\.local\.md$' | grep -v 'templates/okr\.local\.md\.template' || true)"
|
||||
[ -z "$leaks" ] || problems="$problems; tracked state-file leak: $(echo "$leaks" | tr '\n' ' ')"
|
||||
|
||||
# --- SC7: no ../../README.md remains ---
|
||||
if grep -rn '\.\./\.\./README\.md' "$cr" --include='*.md' >/dev/null 2>&1; then
|
||||
problems="$problems; ../../README.md reference remains"
|
||||
fi
|
||||
|
||||
# --- SC7: .gitignore ignores .claude/ ---
|
||||
if ! git -C "$cr" check-ignore .claude/x >/dev/null 2>&1; then
|
||||
problems="$problems; .gitignore does not ignore .claude/"
|
||||
fi
|
||||
|
||||
# --- voyage: .forgejo survival (M17) ---
|
||||
if [ "$key" = "voyage" ]; then
|
||||
if [ ! -d "$cr/.forgejo/ISSUE_TEMPLATE" ]; then
|
||||
problems="$problems; .forgejo/ISSUE_TEMPLATE missing"
|
||||
elif grep -rn '\.\./\.\.' "$cr/.forgejo" >/dev/null 2>&1; then
|
||||
problems="$problems; .forgejo holds a monorepo-relative path"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- SC2: run the plugin's declared runner in the clean room ---
|
||||
local test_cmd advisory
|
||||
test_cmd="$(python3 -c "import json; print(json.load(open('$MAP'))['targets']['$key'].get('test_cmd',''))")"
|
||||
advisory="$(python3 -c "import json; print(json.load(open('$MAP'))['targets']['$key'].get('test_cmd_advisory',''))")"
|
||||
|
||||
# The generic structure validator (Step 7) is referenced by test-less plugins — vendor it into the clean room.
|
||||
case "$test_cmd" in
|
||||
*validate-plugin.generic.sh*)
|
||||
if [ -f "$GENERIC_VALIDATOR" ]; then cp "$GENERIC_VALIDATOR" "$cr/validate-plugin.generic.sh"; fi
|
||||
;;
|
||||
esac
|
||||
|
||||
local sc2_label="standalone-safe"
|
||||
local out status tests
|
||||
out="$(cd "$cr" && eval "$test_cmd" 2>&1)"; status=$?
|
||||
if [ $status -ne 0 ]; then
|
||||
problems="$problems; SC2 runner failed (exit $status)"
|
||||
fi
|
||||
case "$test_cmd" in
|
||||
*node\ --test*)
|
||||
# Node 25's default reporter prints "ℹ tests N"; older/TAP prints "# tests N". Match either.
|
||||
tests="$(printf '%s\n' "$out" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | tail -1)"
|
||||
[ -n "$tests" ] && sc2_label="$tests tests, standalone-safe"
|
||||
;;
|
||||
*validate-plugin*) sc2_label="structure, standalone-safe" ;;
|
||||
esac
|
||||
|
||||
if [ -n "$problems" ]; then
|
||||
echo "$key: FAIL${problems}"
|
||||
FAILS=$((FAILS+1))
|
||||
else
|
||||
echo "$key: PASS ($sc2_label)"
|
||||
[ -n "$advisory" ] && echo " advisory (not a clean-room gate): $advisory"
|
||||
fi
|
||||
}
|
||||
|
||||
for key in $KEYS; do
|
||||
if ! prep_target "$key"; then
|
||||
echo "$key: FAIL (prep/extract error)"
|
||||
FAILS=$((FAILS+1))
|
||||
continue
|
||||
fi
|
||||
validate_target "$key"
|
||||
done
|
||||
|
||||
if [ "$FAILS" -ne 0 ]; then
|
||||
echo "VALIDATE: $FAILS target(s) FAILED"
|
||||
exit 1
|
||||
fi
|
||||
echo "VALIDATE OK"
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
// Step 6 test — the harness is the test surface: it must PASS a clean extract, FAIL one with a
|
||||
// planted tracked state file, and use the glob test form (never a bare dir). Pattern:
|
||||
// plugins/voyage/tests/synthetic/*.test.mjs.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync, cpSync, writeFileSync, readFileSync } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SCRIPT = path.join(here, '40-validate-standalone.sh');
|
||||
const WORK = process.env.WORK || '/tmp/polyrepo-migration';
|
||||
|
||||
function harness(key, work) {
|
||||
// Strip the parent test-runner context so the harness's own `node --test` runs un-nested
|
||||
// (otherwise Node emits its IPC subtest format and the test-count label is suppressed).
|
||||
const env = { ...process.env, WORK: work };
|
||||
delete env.NODE_TEST_CONTEXT;
|
||||
return spawnSync('bash', [SCRIPT, key], { encoding: 'utf8', env });
|
||||
}
|
||||
const git = (dir, args) => spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8' });
|
||||
|
||||
test('harness PASSes a clean standalone extract (graceful-handoff)', () => {
|
||||
const r = harness('graceful-handoff', WORK);
|
||||
assert.equal(r.status, 0, `expected PASS exit 0:\n${r.stdout}\n${r.stderr}`);
|
||||
assert.match(r.stdout, /graceful-handoff: PASS \(.*standalone-safe\)/);
|
||||
});
|
||||
|
||||
test('harness FAILs an extract with a planted tracked state file (SC7)', () => {
|
||||
const tmpWork = mkdtempSync(path.join(os.tmpdir(), 'validate-broken-'));
|
||||
try {
|
||||
const broken = path.join(tmpWork, 'graceful-handoff');
|
||||
cpSync(path.join(WORK, 'graceful-handoff'), broken, { recursive: true });
|
||||
writeFileSync(path.join(broken, 'STATE.md'), '# planted tracked state file\n');
|
||||
assert.equal(git(broken, ['add', '-f', 'STATE.md']).status, 0);
|
||||
assert.equal(git(broken, ['commit', '-m', 'plant tracked STATE.md', '-q']).status, 0);
|
||||
|
||||
const r = harness('graceful-handoff', tmpWork);
|
||||
assert.notEqual(r.status, 0, `expected FAIL (non-zero), got 0:\n${r.stdout}`);
|
||||
assert.match(r.stdout, /graceful-handoff: FAIL/);
|
||||
assert.match(r.stdout, /state-file leak/);
|
||||
} finally {
|
||||
rmSync(tmpWork, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('harness uses the glob test form, never a bare dir (Node 25 gotcha)', () => {
|
||||
const src = readFileSync(SCRIPT, 'utf8');
|
||||
assert.ok(src.includes('*.test.mjs'), 'harness must reference the glob test form *.test.mjs');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue