linkedin-studio/scripts/test-runner.sh
Kjell Tore Guttormsen b89868e3b1 feat(linkedin-studio): research-engine config layer — sources + scoring modes + MCP profile (§5 slice 2a) [skip-docs]
Declaration/config groundwork that slice 2b's trend-spotter upgrade reads.
Standalone (no agent wiring yet — that's 2b), mirroring slice 1's pattern;
[skip-docs] for the same reason slice 1 was — user-facing docs land when 2b
wires the engine live.

- references/trend-scoring-modes.md: methodology SSOT for two rubrics —
  kortform (feed post, timing 20%) + long-form (chronicle, depth 25% / timing 10%,
  per tema-research-motor-spec §4.2). Both sum to 100%. trend-spotter renders from
  this in 2b instead of inlining a matrix (S12-consistent).
- config/trends-sources.template.md: shipped generic source-list defaults →
  user override at ${LINKEDIN_STUDIO_DATA}/trends/sources.md (data-dir, survives
  reinstall; same template->data-dir pattern as user-profile).
- user-profile.template.md: new "Research Tooling" section — declared research MCPs
  (Tavily/Gemini/Perplexity/Other) + WebSearch/WebFetch floor. 2b routes MCP-first.
- setup.md Step 3f + onboarding.md Phase 2: ask "which research MCPs?" -> profile.
  Store only what the user declares; no hard-coded MCP names.
- test-runner.sh: EXPECT_REFS 26->27; generalized the M0 +1 delta-guard into a
  named-post-M0-additions guard (POSTM0_REFS) so a legit later ref doc passes while
  the anti-masking intent holds. Gate green 84/0/0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RBMKqPSVbvSZHtQ4heM1UY
2026-06-22 13:36:28 +02:00

692 lines
32 KiB
Bash
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/bin/bash
# LinkedIn Studio Plugin — Structure Validator
# Validates the REAL v3.1 layout: registration counts (derived dynamically),
# frontmatter shape, hook drift, and plugin.json fields. Counts are asserted
# against the declared contract below, which is kept in sync with the
# CLAUDE.md "## Agents (N)" / "## Commands (N)" headers (cross-checked here)
# and the STATE.md "Telling" block. Adding or removing an agent, command,
# reference, or skill breaks the count-equality and fails the lint — this is
# the registration guard that gates the remediation plan's later steps.
#
# The stat-consistency grep (one magnitude per algorithm effect across the
# tree) was added in remediation Step 3; the version-consistency grep in
# Step 21; the agent model-consistency guard (each agents/<name>.md frontmatter
# model: must match every surface declaration, and canonical rosters must list
# every agent) in S11; the render-chain propagation guard (no honesty pattern a
# command was cleaned of survives in the reference it renders from) in S12; the
# `$`-safety guard (no untrusted value reaches a String.replace replacement STRING
# in state-updater.mjs — proven behaviorally, coverage-complete, self-testing) in
# S13. The external data-dir convention guard (M0: no command/agent/skill/hook prose
# references a migrated user-data path in-plugin — bare or ${CLAUDE_PLUGIN_ROOT}-pinned;
# + no ANALYTICS_ROOT in-plugin pin; + an SC2 dry-run that the tree holds no in-plugin
# user data) in Section 13, each with a non-vacuity self-test; the contract-gate
# binding guard (Slice 3: the §B/§C1 gate's own suite stays green and rules.ts
# ratifies 1:1 against the maskinrommet §E-manifest — KTG-only, skipped for an adopter
# shipping no deps/contract) in Section 14; the specifics-bank binding guard (Fix #2
# slice 3: the lived-specifics store/binding suite stays green and its case count
# never erodes — KTG-only, skipped for an adopter shipping no deps) in Section 15;
# the assertion-count anti-erosion floor (SC6) in Section 16. All are live below
# (Sections 816).
#
# Usage: bash scripts/test-runner.sh
# bash 3.2-safe: plain arrays only, no `declare -A`, no `mapfile`/`readarray`.
set -e
PLUGIN_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PLUGIN_ROOT"
PASS=0
FAIL=0
WARN=0
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
NC='\033[0m' # No Color
pass() { echo -e "${GREEN}${NC} $1"; PASS=$((PASS + 1)); }
fail() { echo -e "${RED}${NC} $1"; FAIL=$((FAIL + 1)); }
warn() { echo -e "${YELLOW}${NC} $1"; WARN=$((WARN + 1)); }
# --- Declared registration contract (the "Telling" block) ---
# Source of truth: CLAUDE.md headers + STATE.md Telling. Bump these together
# with the files when adding/removing an agent, command, reference, or skill.
EXPECT_AGENTS=19
EXPECT_COMMANDS=29
EXPECT_REFS=27
EXPECT_SKILLS=6
# Pre-M0 references/ baseline was 25. Every ref doc added since is NAMED below, so the
# count bump always maps to an intended, named addition — never an incidental doc masked
# by the bump (m3/m11). M0 added data-path-convention.md; each later slice appends its
# doc to POSTM0_REFS. The assert below proves EXPECT_REFS == 25 + 1 (M0) + |POSTM0_REFS|
# AND that every named doc actually exists. bash 3.2-safe: plain indexed array.
REFS_BASELINE_PRE_M0=25
M0_REF="references/data-path-convention.md"
POSTM0_REFS=("references/trend-scoring-modes.md") # research-engine slice 2a (scoring SSOT)
echo "================================================"
echo "LinkedIn Studio Plugin — Structure Validator"
echo "Plugin root: $PLUGIN_ROOT"
echo "================================================"
echo ""
# --- Section 1: Core Files ---
echo "--- Core Files ---"
for f in ".claude-plugin/plugin.json" "CLAUDE.md" "CHANGELOG.md" "README.md" "config/REMEMBER.template.md"; do
if [ -f "$f" ]; then
pass "$f exists"
else
fail "$f MISSING"
fi
done
echo ""
# --- Section 2: Registration Counts (dynamic) ---
echo "--- Registration Counts ---"
AGENTS=$(ls agents/*.md 2>/dev/null | wc -l | tr -d ' ')
COMMANDS=$(ls commands/*.md 2>/dev/null | wc -l | tr -d ' ')
REFS=$(ls references/*.md 2>/dev/null | wc -l | tr -d ' ')
SKILLS=$(ls skills/*/SKILL.md 2>/dev/null | wc -l | tr -d ' ')
assert_count() {
# $1 label, $2 actual, $3 expected
if [ "$2" -eq "$3" ]; then
pass "$1: $2 (expected $3)"
else
fail "$1: $2 (expected $3) — registration drift"
fi
}
assert_count "agents/*.md" "$AGENTS" "$EXPECT_AGENTS"
assert_count "commands/*.md" "$COMMANDS" "$EXPECT_COMMANDS"
assert_count "references/*.md" "$REFS" "$EXPECT_REFS"
assert_count "skills/*/SKILL.md" "$SKILLS" "$EXPECT_SKILLS"
# references/ count must map 1:1 to the NAMED additions (M0 + every later slice's doc),
# and each named doc must exist. Guards against the bump silently absorbing an incidental
# extra ref doc (m3/m11). To add a ref doc: append it to POSTM0_REFS and bump EXPECT_REFS.
NAMED_REFS_OK=1
[ -f "$M0_REF" ] || NAMED_REFS_OK=0
for r in "${POSTM0_REFS[@]}"; do
[ -f "$r" ] || NAMED_REFS_OK=0
done
EXPECT_NAMED=$((REFS_BASELINE_PRE_M0 + 1 + ${#POSTM0_REFS[@]}))
if [ "$EXPECT_REFS" -eq "$EXPECT_NAMED" ] && [ "$NAMED_REFS_OK" -eq 1 ]; then
pass "references/ count maps to named additions (${REFS_BASELINE_PRE_M0} baseline +1 M0 +${#POSTM0_REFS[@]} post-M0 = ${EXPECT_REFS}; all named docs exist)"
else
fail "references/ count != named additions — bump EXPECT_REFS and name the doc in POSTM0_REFS (expected ${EXPECT_NAMED}, have ${EXPECT_REFS}; named-docs-exist=${NAMED_REFS_OK})"
fi
# Cross-check the CLAUDE.md declared headers against the contract (doc-drift guard)
DOC_AGENTS=$(grep -oE '^## Agents \([0-9]+\)' CLAUDE.md | grep -oE '[0-9]+' | head -1)
DOC_COMMANDS=$(grep -oE '^## Commands \([0-9]+\)' CLAUDE.md | grep -oE '[0-9]+' | head -1)
if [ "$DOC_AGENTS" = "$EXPECT_AGENTS" ]; then
pass "CLAUDE.md '## Agents ($DOC_AGENTS)' matches contract"
else
fail "CLAUDE.md agents header ($DOC_AGENTS) != contract ($EXPECT_AGENTS)"
fi
if [ "$DOC_COMMANDS" = "$EXPECT_COMMANDS" ]; then
pass "CLAUDE.md '## Commands ($DOC_COMMANDS)' matches contract"
else
fail "CLAUDE.md commands header ($DOC_COMMANDS) != contract ($EXPECT_COMMANDS)"
fi
# README shields commands-count badge must match the contract too. Added after an
# S14 /trekreview found the badge stale at commands-27 while the surface shipped 29:
# the version-consistency grep (Section 9) checks only the version badge, and the
# count guards above check the CLAUDE.md header, so the README count badge slipped
# both. This closes that gap (the count-badge analogue of the version-badge check).
if grep -q "badge/commands-${EXPECT_COMMANDS}-" README.md; then
pass "README commands badge declares ${EXPECT_COMMANDS}"
else
fail "README commands badge != ${EXPECT_COMMANDS} (expected shields badge/commands-${EXPECT_COMMANDS}-)"
fi
echo ""
# --- Section 3: Agent Frontmatter ---
echo "--- Agent Frontmatter ---"
for f in agents/*.md; do
if head -1 "$f" | grep -q "^---"; then
if grep -q "^name:" "$f" && grep -q "^description:" "$f"; then
pass "$f (frontmatter OK)"
else
fail "$f (missing name:/description:)"
fi
else
fail "$f (no YAML frontmatter)"
fi
done
echo ""
# --- Section 4: Command Frontmatter ---
echo "--- Command Frontmatter ---"
for f in commands/*.md; do
if head -1 "$f" | grep -q "^---"; then
if grep -q "^name:" "$f" && grep -q "^description:" "$f"; then
pass "$f (frontmatter OK)"
else
fail "$f (missing name:/description:)"
fi
else
fail "$f (no YAML frontmatter)"
fi
done
echo ""
# --- Section 5: Hook Configuration (drift) ---
echo "--- Hook Configuration ---"
if [ -f "hooks/hooks.json" ]; then
pass "hooks/hooks.json exists"
if python3 hooks/scripts/compile-hooks.py --check >/dev/null 2>&1; then
pass "hooks.json matches compiled template (no drift)"
else
fail "hooks.json DRIFT — run: python3 hooks/scripts/compile-hooks.py"
fi
else
fail "hooks/hooks.json MISSING"
fi
echo ""
# --- Section 6: Plugin.json Validation ---
echo "--- Plugin.json Validation ---"
if python3 -c "
import json, sys
with open('.claude-plugin/plugin.json') as f:
data = json.load(f)
required = ['name', 'version', 'description']
missing = [field for field in required if field not in data]
if missing:
print('Missing fields:', missing)
sys.exit(1)
print('Version:', data['version'])
" 2>/dev/null; then
pass "plugin.json structure valid (name/version/description)"
else
fail "plugin.json structure invalid"
fi
echo ""
# --- Section 7: Analytics Source ---
echo "--- Analytics Source ---"
if [ -f "scripts/analytics/src/cli.ts" ]; then
pass "scripts/analytics/src/cli.ts exists"
else
fail "scripts/analytics/src/cli.ts MISSING"
fi
echo ""
# --- Section 8: Algorithm-Stat Consistency ---
echo "--- Algorithm-Stat Consistency ---"
# The single source of truth for algorithm magnitudes is
# references/algorithm-signals-reference.md. After the Phase-0 reconciliation,
# stale/competing magnitudes — the retired engagement-coefficient folklore, the
# unpublishable model params/brand, and the deployment date — must not reappear
# anywhere else (cite the reference, do not restate). This enforces "one magnitude
# per algorithm effect" by forbidding EVERY retired-class value from returning, so
# the same grep that defines the Phase-0 Success Criterion fails on any survivor.
#
# S9 rebuild: the S8 list forbade only the two S7-named strings and went green
# over six more survivors (the coefficient system in analytics-interpreter/
# content-optimizer/pipeline/glossary, the playbook 15x/5x, the 150B model). This
# list is rebuilt to the FULL criterion. Forbidden classes (each maps to a
# canonical statement in the reference):
# - Carousel-rate folklore: 6.6% / 6.60% / 1.92% → reference: "~7% top format"
# - Link-penalty folklore: 40-50% / 25-40% / -40-60% → reference: one ~38% correlational band
# - Comment-multiplier folklore: "15x more reach/algorithmic", "5x more effective/
# less valuable/reach than" → reference: order only, comment ≈ 2x a like
# - Video-multiplier folklore: "5x more conversations" → reference: video declining, no multiplier
# - Engagement-coefficient system: 7-9x, 2.5x, 0.2x, (10x), (8x), "10x weight"
# → reference: "never hard coefficients to optimize against"
# - Model params/brand/date: the PATTERN CLASS [0-9]+[ -]?(B|billion)?[ -]?param
# (covers 150-parameter / 150B param / 150 billion param) / 360Brew / January 2026
# → reference: "Not publishable as fact"
#
# S10: the model-precision token is now the pattern CLASS, not a literal-token
# list. S9 forbade only "150 ?B param|150 billion param"; a hyphenated
# "150-parameter" (no "B") slipped both the discovery grep and the lint, surviving
# in glossary.md:10. The criterion is "no asserted model precision in ANY surface
# form", so the lint now enforces the shape (a number adjacent to "param"), not an
# enumeration. An adjacent digit is REQUIRED, so legitimate "param" uses with no
# leading number — "Language parameter", "parameterized", "different parameters",
# "«parametere»", "175-milliarders parametermodell" — do not match.
# Bare "10x"/"15x"/"5x" are deliberately NOT forbidden — they carry legitimate
# uses (collaboration "10x your reach" hyperbole, "5x5x5", posting cadence, pixel
# dims like 1080x1350), so each token targets the retired *phrasing*, not the bare
# number.
#
# Scope covers every dir the criterion's grep covers, including assets/checklists/
# (the 360Brew survivor lived there, outside the S8 scan), assets/templates/, and
# CHANGELOG.md (S10: the 360Brew/January-2026 survivor lived there, outside the S9
# scope). assets/{templates,checklists}/ — not all of assets/ — keeps the scan off
# gitignored runtime data (assets/analytics/, assets/drafts/, voice-samples/).
STALE_STATS='40-50%|25-40%|6\.6%|6\.60%|1\.92%|15x more reach|15x more algorithmic|5x more effective|5x less valuable|5x more reach than|5x more conversations|7-9x|2\.5x|0\.2x|\(10x\)|\(8x\)|10x weight|-40-60%|[0-9]+[ -]?(B|billion)?[ -]?param|360Brew|January 2026'
# Non-vacuity self-test (S10). A grep criterion is only meaningful if it actually
# MATCHES the forbidden forms and does NOT match legitimate ones. S7→S9 each
# shipped a lint that passed green while a survivor slipped, because the proof was
# run once by hand and never committed — so a hyphenated "150-parameter" form was
# never re-checked. This makes the proof PERMANENT: it runs on every invocation
# BEFORE the real scan, so narrowing STALE_STATS back to a literal-token list fails
# the suite instead of silently certifying an unenforced criterion. The positive
# set covers all three model-precision surface forms (incl. the exact S10
# "150-parameter" survivor); the negative set covers the legitimate "param"/x uses
# that live in the tree today.
SELFTEST_OK=1
while IFS= read -r probe; do
[ -z "$probe" ] && continue
if ! echo "$probe" | grep -qE "$STALE_STATS"; then
SELFTEST_OK=0; echo " non-vacuity FAIL: forbidden form not caught -> $probe"
fi
done <<'POSITIVE'
40-50% link penalty
6.6% carousel rate
1.92% reach
15x more reach
5x more conversations
7-9x weight
(10x) coefficient
10x weight
150-parameter foundation model
150B parameter foundation model
150 billion parameter model
360Brew
January 2026 algorithm update
POSITIVE
while IFS= read -r probe; do
[ -z "$probe" ] && continue
if echo "$probe" | grep -qE "$STALE_STATS"; then
SELFTEST_OK=0; echo " false-positive FAIL: legitimate form caught -> $probe"
fi
done <<'NEGATIVE'
5x5x5 pre-posting method
post 3x per week
1080x1350 pixels
10x your reach
Language parameter (configurable)
parameterized content-gatekeeper
Start over with different parameters
175-milliarders parametermodell
NEGATIVE
if [ "$SELFTEST_OK" -eq 1 ]; then
pass "STALE_STATS self-test: 13 forbidden forms caught, 8 legitimate forms ignored"
else
fail "STALE_STATS self-test failed — the lint no longer enforces the full criterion"
fi
STAT_HITS=$(grep -rnE "$STALE_STATS" references/ commands/ skills/ hooks/prompts/ agents/ assets/templates/ assets/checklists/ CLAUDE.md README.md CHANGELOG.md .claude-plugin/plugin.json 2>/dev/null | grep -v 'algorithm-signals-reference' || true)
if [ -z "$STAT_HITS" ]; then
pass "no stale algorithm magnitudes / model brand outside the canonical reference"
else
fail "stale algorithm stat(s) reintroduced — cite algorithm-signals-reference.md instead:"
echo "$STAT_HITS"
fi
echo ""
# --- Section 9: Version Consistency ---
echo "--- Version Consistency ---"
# Single source of truth for the plugin version: .claude-plugin/plugin.json.
# Its value must be declared identically in the README badge, the CLAUDE.md
# header, and the CHANGELOG top entry. Historical references to older versions
# (CHANGELOG history, the README version-history table, "vX added Y" prose) are
# NOT checked here — only the current-version DECLARATIONS must agree.
VERSION=$(python3 -c "import json; print(json.load(open('.claude-plugin/plugin.json'))['version'])" 2>/dev/null)
if [ -z "$VERSION" ]; then
fail "could not read version from plugin.json"
else
pass "plugin.json version: $VERSION"
if grep -q "version-${VERSION}-blue" README.md; then
pass "README badge declares v$VERSION"
else
fail "README badge does not declare v$VERSION (expected version-${VERSION}-blue)"
fi
if grep -q "LinkedIn Studio Plugin (v${VERSION})" CLAUDE.md; then
pass "CLAUDE.md header declares v$VERSION"
else
fail "CLAUDE.md header does not declare (v$VERSION)"
fi
if grep -q "^## \[${VERSION}\]" CHANGELOG.md; then
pass "CHANGELOG has a [$VERSION] entry"
else
fail "CHANGELOG missing a [$VERSION] entry"
fi
fi
echo ""
# --- Section 10: Agent Model-Consistency ---
echo "--- Agent Model-Consistency ---"
# Each agents/<name>.md frontmatter `model:` is the source of truth; every
# surface that DECLARES an agent's model (README, CLAUDE.md, the capability
# matrix, the SKILL rosters) must match it, and the canonical rosters must list
# every agent. Added in S11 after a cold full-brief review found
# post-feedback-monitor published as Haiku across four surfaces while the agent
# runs Opus — declaration drift the version/count/stat guards could not see. The
# checker self-tests its own non-vacuity on every run (see the .mjs header):
# a deliberately-mismatched probe must be caught and a correct one ignored, else
# the suite fails instead of certifying an unenforced criterion.
if node scripts/check-model-consistency.mjs; then
pass "agent model-consistency: all surface declarations match frontmatter + canonical rosters complete"
else
fail "agent model-consistency drift — see check-model-consistency.mjs output above"
fi
echo ""
# --- Section 11: Render-Chain Propagation ---
echo "--- Render-Chain Propagation ---"
# Commands render from the references they inline via
# ${CLAUDE_PLUGIN_ROOT}/references/…. An honesty pattern removed from a command
# surface must NOT survive in the reference that command renders from — otherwise
# the user still hits it. Added in S12 after a cold full-brief review found the
# banned A/B significance-verdict column (`Significant? (>20%)` with Yes/No cells)
# still shipping in references/ab-testing-framework.md while commands/ab-test.md had
# already been cleaned to the honest "Directional?" framing. The command-level fix
# never propagated to its render-source, and Section 8's STALE_STATS grep targets
# magnitudes, not this construct, so the survivor passed green. This generalizes the
# fix from "clean the command" to "the banned construct is forbidden across the
# WHOLE render chain (commands AND references)". Future propagation-class patterns
# get appended to PROP_FORBIDDEN, mirroring how Section 8's STALE_STATS grew to the
# full criterion rather than the single named token.
#
# Forbidden: the significance-VERDICT column — `Significant?` adjacent to a `(` (the
# `(>20%)` verdict parenthetical) or a table pipe (`| Significant?`). The defect is a
# column steering users to record a statistical-significance call that organic
# personal-post volume never reaches; "directional" is the honest frame. Legitimate
# descriptive prose ("Significantly higher", "Significant capability", "statistical
# significance", a bare sentence-final "significant?") carries no `(`/`|`-adjacency
# and is left alone.
PROP_FORBIDDEN='Significant\?[[:space:]]*\(|\|[[:space:]]*Significant\?'
# Non-vacuity self-test (mirrors Section 8): the criterion is only meaningful if it
# MATCHES the verdict-column forms and IGNORES legitimate prose. Runs on every
# invocation BEFORE the real scan, so weakening the pattern fails the suite instead
# of silently certifying an unenforced guard. The positive set covers the exact S12
# survivor + its bare-column variant; the negative set covers the honest
# "Directional?" fix and every legitimate "Significant"/"significance" string the
# tree carries today.
PROP_SELFTEST_OK=1
while IFS= read -r probe; do
[ -z "$probe" ] && continue
if ! echo "$probe" | grep -qE "$PROP_FORBIDDEN"; then
PROP_SELFTEST_OK=0; echo " non-vacuity FAIL: forbidden form not caught -> $probe"
fi
done <<'PROP_POSITIVE'
| Difference | Significant? (>20%) |
Significant? (>20%)
| Significant? |
PROP_POSITIVE
while IFS= read -r probe; do
[ -z "$probe" ] && continue
if echo "$probe" | grep -qE "$PROP_FORBIDDEN"; then
PROP_SELFTEST_OK=0; echo " false-positive FAIL: legitimate form caught -> $probe"
fi
done <<'PROP_NEGATIVE'
| Difference | Directional? (>20% gap) |
Significantly higher weight than generic responses
Significant capability breakthroughs
Significantly Behind (<50%)
LinkedIn analytics does not support statistical significance tests
Is the difference significant? Probably not.
PROP_NEGATIVE
if [ "$PROP_SELFTEST_OK" -eq 1 ]; then
pass "render-chain propagation self-test: 3 verdict-column forms caught, 6 legitimate forms ignored"
else
fail "render-chain propagation self-test failed — the guard no longer enforces the criterion"
fi
# Real scan across the whole user-facing render chain (commands + every reference
# they inline) plus the adjacent surfaces a copy could migrate the table into.
PROP_HITS=$(grep -rnE "$PROP_FORBIDDEN" references/ commands/ skills/ hooks/prompts/ agents/ assets/templates/ assets/checklists/ 2>/dev/null || true)
if [ -z "$PROP_HITS" ]; then
pass "no significance-verdict column survives in any command or its render-source reference"
else
fail "significance-verdict column reintroduced — use the honest 'Directional?' framing (see commands/ab-test.md):"
echo "$PROP_HITS"
fi
echo ""
# --- Section 12: `$`-Safety (String.replace replacement) ---
echo "--- \$-Safety (String.replace replacement) ---"
# state-updater.mjs mutates the state file from untrusted user content (post
# topics, hooks, targets, partners, …). In a JS replacement *string*, `$&`/`` $` ``/
# `$'`/`$$`/`$n` are special, so a `$`-bearing value rewrites the field; a
# replacement *function* inserts its return verbatim. Added in S13 after a cold
# full-brief review found the LAST member of this class: S12 converted the 5
# section-append sites to functions but left `replaceField` (the scalar writer) on a
# replacement string, and the S12 `$`-test asserted only the section entry — never
# the `last_post_topic` scalar — so the corruption shipped green. This is the S9→S12
# "close the class, not the line" lesson on the `$`-axis: rather than grep a
# syntactic proxy (which cannot tell a replacement-position template literal from a
# RegExp-pattern one across multi-line calls), check-replace-safety.mjs drives EVERY
# exported mutator with an adversarial payload of every special token in every
# free-text + date field and asserts verbatim survival. Two structural backstops run
# inside it on every invocation: COVERAGE-COMPLETENESS (a new export without
# `$`-coverage fails) and a NON-VACUITY SELF-TEST (a naive string-replace MUST
# corrupt the payload, a function MUST preserve it — else a PASS is meaningless),
# mirroring Section 8/10/11.
if node scripts/check-replace-safety.mjs; then
pass "\$-safety: no untrusted value reaches a String.replace replacement string (behavioral, coverage-complete, self-testing)"
else
fail "\$-safety guard failed — a state-updater String.replace replacement is \$-unsafe; see check-replace-safety.mjs output above"
fi
echo ""
# --- Section 13: External Data-Dir Convention (R1 — M0) ---
echo "--- External Data-Dir Convention ---"
# After the M0 migration (user data lives under ${LINKEDIN_STUDIO_DATA:-$HOME/.claude/
# linkedin-studio}/), no command/agent/skill/hook prose may reference a MIGRATED
# user-data path in-plugin — neither bare (assets/<x>/) nor ${CLAUDE_PLUGIN_ROOT}-
# prefixed — or a command writes the external dir while Claude follows stale in-plugin
# prose (R1, brief §12.6). Shipped read-only assets keep ${CLAUDE_PLUGIN_ROOT} and are
# exempt: the analytics README, every *-template.md seed, assets/checklists/,
# assets/quick-post-resources.md, the shipped assets/templates/*, config/*.template.*.
# config/personas.local.md is a deliberate in-plugin fallback library (un-migrated, out
# of M0 scope) and is NOT in the class. Scope = the actionable prose dirs (commands/
# agents/skills/hooks-prompts); references/ is documentation (the convention doc
# legitimately names scaffold paths) and is excluded.
BARE_DATA='(\$\{CLAUDE_PLUGIN_ROOT\}/)?assets/(voice-samples|analytics|drafts|plans|audience-insights|examples|frameworks|case-studies|network)/|(\$\{CLAUDE_PLUGIN_ROOT\}/)?assets/repurposing-tracker\.md|(\$\{CLAUDE_PLUGIN_ROOT\}/)?assets/templates/my-post-templates\.md|(\$\{CLAUDE_PLUGIN_ROOT\}/)?config/user-profile\.local\.md'
BARE_EXEMPT='README\.md|-template\.md'
# Non-vacuity self-test (mirrors Section 8): the full criterion is regex-match AND
# not-exempt. Positives MUST be violations; negatives (external token + every shipped
# exemption + the out-of-scope personas lib) MUST NOT be.
B13_OK=1
while IFS= read -r probe; do
[ -z "$probe" ] && continue
if echo "$probe" | grep -qE "$BARE_DATA" && ! echo "$probe" | grep -qE "$BARE_EXEMPT"; then :; else
B13_OK=0; echo " non-vacuity FAIL: in-plugin data path not flagged -> $probe"
fi
done <<'POSITIVE13'
assets/voice-samples/authentic-voice-samples.md
${CLAUDE_PLUGIN_ROOT}/assets/analytics/posts/
assets/drafts/queue.json
assets/templates/my-post-templates.md
assets/frameworks/ai-maturity-model.md
assets/repurposing-tracker.md
config/user-profile.local.md
POSITIVE13
while IFS= read -r probe; do
[ -z "$probe" ] && continue
if echo "$probe" | grep -qE "$BARE_DATA" && ! echo "$probe" | grep -qE "$BARE_EXEMPT"; then
B13_OK=0; echo " false-positive FAIL: legitimate path flagged -> $probe"
fi
done <<'NEGATIVE13'
${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/posts/
assets/analytics/README.md
assets/frameworks/framework-template.md
assets/case-studies/case-study-template.md
assets/checklists/quality-scorecard.md
assets/quick-post-resources.md
config/user-profile.template.md
config/personas.local.md
NEGATIVE13
if [ "$B13_OK" -eq 1 ]; then
pass "no-bare-path self-test: 7 in-plugin data paths caught, 8 legitimate/exempt forms ignored"
else
fail "no-bare-path self-test failed — the R1 criterion no longer enforces in-plugin data-path detection"
fi
BARE_HITS=$(grep -rnE "$BARE_DATA" commands/ agents/ skills/ hooks/prompts/ 2>/dev/null | grep -vE "$BARE_EXEMPT" || true)
if [ -z "$BARE_HITS" ]; then
pass "no command/agent/skill/hook prose references a migrated user-data path in-plugin (R1)"
else
fail "in-plugin user-data path(s) in prose — route via \${LINKEDIN_STUDIO_DATA:-\$HOME/.claude/linkedin-studio}/ (see references/data-path-convention.md):"
echo "$BARE_HITS"
fi
# Sibling no-pin guard (M3): the no-bare-path grep cannot catch an ANALYTICS_ROOT alias
# pinned to the in-plugin analytics dir — Step 11 dropped these; forbid their return.
PIN='ANALYTICS_ROOT=.{0,2}\$\{CLAUDE_PLUGIN_ROOT\}/assets/analytics'
PIN_OK=1
echo 'ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics"' | grep -qE "$PIN" || { PIN_OK=0; echo " non-vacuity FAIL: in-plugin pin not caught"; }
if echo 'ANALYTICS_ROOT="${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics"' | grep -qE "$PIN"; then PIN_OK=0; echo " false-positive FAIL: external alias caught"; fi
if [ "$PIN_OK" -eq 1 ]; then
pass "no-pin self-test: in-plugin ANALYTICS_ROOT pin caught, external alias ignored"
else
fail "no-pin self-test failed — the M3 pin criterion is not enforced"
fi
PIN_HITS=$(grep -rnE "$PIN" commands/ agents/ skills/ hooks/prompts/ 2>/dev/null || true)
if [ -z "$PIN_HITS" ]; then
pass "no prose pins ANALYTICS_ROOT to the in-plugin analytics dir (M3)"
else
fail "ANALYTICS_ROOT pinned to in-plugin analytics — drop the pin (external default applies):"
echo "$PIN_HITS"
fi
# SC2 dry-run: the migrated in-plugin user-data classes (.gitignore) must hold NO data
# files — after M0 they live external. --ignored surfaces gitignored stragglers a plain
# porcelain would hide; filtered to the data classes, it must be empty. Catches a flow
# (or a stray file) that wrote user data back into the plugin tree (SC2). personas is
# excluded (un-migrated, out of M0 scope — consistent with Section 13's class).
SC2_CLASSES='assets/analytics/(exports|posts|weekly-reports|monthly-reports)/|assets/analytics/content-history\.md|assets/drafts/queue\.json|assets/drafts/week-|assets/voice-samples/[^ ]*\.local\.md|config/user-profile\.local\.md'
SC2_DIRT=$(git status --porcelain --ignored 2>/dev/null | grep -E "$SC2_CLASSES" || true)
if [ -z "$SC2_DIRT" ]; then
pass "SC2 dry-run: no in-plugin user-data files (analytics/drafts/voice/profile) in the tree"
else
fail "SC2 violation — migrated user data sits in the plugin tree (should be external):"
echo "$SC2_DIRT"
fi
echo ""
# --- Section 14: Contract-Gate Binding (Slice 3) ---
echo "--- Contract-Gate Binding ---"
# The deterministic §B/§C1 contract-gate (scripts/contract-gate) is wired into
# /linkedin:newsletter as Step 4.5. Two invariants belong in CI, not per-edition:
# (14a) the gate's own test suite stays green and its case count never erodes; and
# (14b) rules.ts stays bound 1:1 to the maskinrommet skrivekontrakt §E-manifest
# (ratify). Both are KTG-internal: skipped (warn, never fail) for an adopter that
# ships no contract-gate deps or no contract — so the lint stays green everywhere.
# bash 3.2-safe; `set +e` inside the test command-substitution keeps a red npm test
# from aborting the whole runner (set -e is active), and ratify exit 1 is captured by
# the `if (subshell)` form (condition context is exempt from set -e).
CG_DIR="scripts/contract-gate"
if [ -x "$CG_DIR/node_modules/.bin/tsx" ]; then
# 14a: the gate suite is green (npm test exit 0) and holds its case-count floor.
CG_OUT=$( set +e; (cd "$CG_DIR" && npm test) 2>&1; echo "CG_EXIT:$?" )
CG_EXIT=$(echo "$CG_OUT" | grep -oE 'CG_EXIT:[0-9]+' | grep -oE '[0-9]+' | head -1)
CG_TESTS=$(echo "$CG_OUT" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | tail -1)
CONTRACT_GATE_TESTS_FLOOR=33
if [ "$CG_EXIT" = "0" ] && [ -n "$CG_TESTS" ] && [ "$CG_TESTS" -ge "$CONTRACT_GATE_TESTS_FLOOR" ]; then
pass "contract-gate suite green: $CG_TESTS tests pass (floor $CONTRACT_GATE_TESTS_FLOOR)"
else
fail "contract-gate suite NOT green (exit=${CG_EXIT:-?}, tests=${CG_TESTS:-?}, floor $CONTRACT_GATE_TESTS_FLOOR) — run: (cd $CG_DIR && npm test)"
fi
# 14b: ratify — rules.ts <-> §E-manifest 1:1. Skipped when the contract is absent.
CONTRACT="${MASKINROMMET_CONTRACT:-$PLUGIN_ROOT/../../maskinrommet/docs/skrivekontrakt.md}"
if [ -f "$CONTRACT" ]; then
if (cd "$CG_DIR" && node --import tsx src/cli.ts --ratify) >/dev/null 2>&1; then
pass "contract-gate ratify: rules.ts bound 1:1 to the §E-manifest (in sync)"
else
fail "contract-gate ratify DRIFT — rules.ts != §E-manifest; run: (cd $CG_DIR && npm run ratify)"
fi
else
warn "contract-gate ratify skipped — contract absent ($CONTRACT); KTG-only invariant"
fi
else
warn "contract-gate skipped — deps absent ($CG_DIR/node_modules); run: (cd $CG_DIR && npm install)"
fi
echo ""
# --- Section 15: Specifics-Bank Binding (Fix #2 slice 3) ---
echo "--- Specifics-Bank Binding ---"
# The lived-specifics store + per-edition binding (scripts/specifics-bank) is wired
# into /linkedin:newsletter as Step 1.5 (elicitation + slot-map) and its
# validate-binding gate into the Step 2.5 skeleton-gate. The seam is now load-bearing,
# so its suite belongs in CI: the bank/binding/kilder tests stay green and the case
# count never erodes. KTG-internal: skipped (warn, never fail) for an adopter that
# ships no specifics-bank deps — so the lint stays green everywhere. Same set +e /
# subshell discipline as Section 14 (bash 3.2-safe; keeps a red npm test from
# aborting the runner under set -e).
SB_DIR="scripts/specifics-bank"
if [ -x "$SB_DIR/node_modules/.bin/tsx" ]; then
SB_OUT=$( set +e; (cd "$SB_DIR" && npm test) 2>&1; echo "SB_EXIT:$?" )
SB_EXIT=$(echo "$SB_OUT" | grep -oE 'SB_EXIT:[0-9]+' | grep -oE '[0-9]+' | head -1)
SB_TESTS=$(echo "$SB_OUT" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | tail -1)
SPECIFICS_BANK_TESTS_FLOOR=28
if [ "$SB_EXIT" = "0" ] && [ -n "$SB_TESTS" ] && [ "$SB_TESTS" -ge "$SPECIFICS_BANK_TESTS_FLOOR" ]; then
pass "specifics-bank suite green: $SB_TESTS tests pass (floor $SPECIFICS_BANK_TESTS_FLOOR)"
else
fail "specifics-bank suite NOT green (exit=${SB_EXIT:-?}, tests=${SB_TESTS:-?}, floor $SPECIFICS_BANK_TESTS_FLOOR) — run: (cd $SB_DIR && npm test)"
fi
else
warn "specifics-bank skipped — deps absent ($SB_DIR/node_modules); run: (cd $SB_DIR && npm install)"
fi
echo ""
# --- Section 16: Assertion-Count Anti-Erosion (SC6) ---
# The lint self-modifies its own checks, so a green run could mask a silently dropped
# assertion. Pin the pre-M0 total (74 pass()+fail() invocations) as a floor; the count
# may only grow (brief-reviewer assumption 3). Runs last so TOTAL_CHECKS sees every prior check.
ASSERT_BASELINE_PRE_M0=74
TOTAL_CHECKS=$((PASS + FAIL))
if [ "$TOTAL_CHECKS" -ge "$ASSERT_BASELINE_PRE_M0" ]; then
pass "assertion-count anti-erosion: $TOTAL_CHECKS checks >= pre-M0 baseline $ASSERT_BASELINE_PRE_M0"
else
fail "assertion count $TOTAL_CHECKS < pre-M0 baseline $ASSERT_BASELINE_PRE_M0 — a check was silently removed"
fi
echo ""
# --- Summary ---
echo "================================================"
echo "RESULTS"
echo "================================================"
echo -e "${GREEN}Passed: $PASS${NC}"
echo -e "${RED}Failed: $FAIL${NC}"
echo -e "${YELLOW}Warnings: $WARN${NC}"
echo ""
if [ $FAIL -eq 0 ]; then
echo -e "${GREEN}All structural checks passed!${NC}"
exit 0
else
echo -e "${RED}$FAIL check(s) failed. Review above.${NC}"
exit 1
fi