@windyroad/itil 0.24.0 → 0.24.1-preview.270

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "wr-itil",
3
- "version": "0.24.0",
3
+ "version": "0.24.1",
4
4
  "description": "ITIL-aligned IT service management for Claude Code"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windyroad/itil",
3
- "version": "0.24.0",
3
+ "version": "0.24.1-preview.270",
4
4
  "description": "ITIL-aligned IT service management for Claude Code (problem, and future incident/change skills)",
5
5
  "bin": {
6
6
  "windyroad-itil": "./bin/install.mjs"
@@ -24,6 +24,7 @@
24
24
  "agents/",
25
25
  "hooks/",
26
26
  "skills/",
27
+ "scripts/",
27
28
  ".claude-plugin/",
28
29
  "lib/"
29
30
  ]
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env bash
2
+ # packages/itil/scripts/check-problems-readme-budget.sh
3
+ #
4
+ # Diagnose-only advisory script for the docs/problems/README.md "Last
5
+ # reviewed" line (line 3). Sibling to P099's
6
+ # packages/retrospective/scripts/check-briefing-budgets.sh on a
7
+ # different surface — both apply ADR-040 line 92's reusable triplet
8
+ # (read-only diagnostic + behavioural bats + ADR-tier-budget
9
+ # enforcement clause) to an accumulator-doc surface.
10
+ #
11
+ # Usage:
12
+ # check-problems-readme-budget.sh [<readme-path>]
13
+ #
14
+ # Default <readme-path> is ./docs/problems/README.md.
15
+ # Threshold is read from PROBLEMS_README_LINE3_MAX_BYTES (default 5120
16
+ # — matches ADR-040 Tier 3 Tier 3 envelope; the soft per-fragment cap
17
+ # is 1024 bytes, enforced by the SKILL.md authoring contracts in
18
+ # manage-problem / transition-problem / transition-problems /
19
+ # review-problems / reconcile-readme).
20
+ #
21
+ # Exit codes:
22
+ # 0 = always (advisory only — overflow is signal, not failure)
23
+ # 2 = parse error (README path missing or unreadable)
24
+ #
25
+ # Output format on overflow (one line, terse machine-readable per
26
+ # ADR-038 progressive-disclosure budget):
27
+ # OVER <readme-path> line=3 bytes=<N> threshold=<N>
28
+ #
29
+ # Output is empty (no lines) when line 3 is under the threshold.
30
+ #
31
+ # Read-only — does NOT mutate the README. Truncation is owned by the
32
+ # per-operation refresh contracts in manage-problem Step 5 P094 +
33
+ # Step 7 P062 (and the sibling skills).
34
+ #
35
+ # @problem P134
36
+ # @adr ADR-040 (Session-start briefing surface — Tier 3 budget; line 92's
37
+ # reusable-pattern note explicitly names "problems index" as a
38
+ # candidate surface for this triplet)
39
+ # @adr ADR-038 (Progressive disclosure — per-row terse budget)
40
+ # @adr ADR-013 Rule 6 (non-interactive fail-safe — advisory exit 0)
41
+ # @adr ADR-005 (Plugin testing strategy)
42
+ # @jtbd JTBD-001 / JTBD-006 / JTBD-101
43
+
44
+ set -uo pipefail
45
+
46
+ README_PATH="${1:-docs/problems/README.md}"
47
+ THRESHOLD="${PROBLEMS_README_LINE3_MAX_BYTES:-5120}"
48
+
49
+ # ── Pre-checks ──────────────────────────────────────────────────────────────
50
+
51
+ if [ ! -f "$README_PATH" ]; then
52
+ echo "check-problems-readme-budget: README not found: $README_PATH" >&2
53
+ exit 2
54
+ fi
55
+
56
+ # ── Measure line 3 byte size ────────────────────────────────────────────────
57
+ # `awk 'NR==3'` extracts line 3 verbatim; the trailing newline that awk
58
+ # emits is not part of the line's content. We use printf '%s' to feed
59
+ # the line to wc -c with no added newline so the byte count matches the
60
+ # in-file content of line 3. Empty / missing line 3 yields 0 bytes.
61
+
62
+ line3="$(awk 'NR==3' "$README_PATH")"
63
+ bytes=$(printf '%s' "$line3" | wc -c | tr -d ' ')
64
+
65
+ if [ "$bytes" -ge "$THRESHOLD" ] && [ "$bytes" -gt 0 ]; then
66
+ echo "OVER $README_PATH line=3 bytes=$bytes threshold=$THRESHOLD"
67
+ fi
68
+
69
+ # Edge case: threshold of 0 with non-empty line 3 — still report (sanity
70
+ # path exercised in bats). The first guard above requires bytes > 0 so
71
+ # we never emit OVER for an empty/missing line 3.
72
+
73
+ exit 0
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env bash
2
+ # packages/itil/scripts/classify-readme-drift.sh
3
+ #
4
+ # Classify reconcile-readme.sh exit-1 drift output as either
5
+ # inline-refresh-deferrable (covered by uncommitted ticket renames in the
6
+ # working tree — in-flow P094/P062 refresh will land the README correction
7
+ # in the upcoming commit per ADR-014) or halt-route-reconcile (committed
8
+ # cross-session drift — must route to /wr-itil:reconcile-readme).
9
+ #
10
+ # Usage:
11
+ # classify-readme-drift.sh <drift-stdout-file> [<problems-dir>]
12
+ #
13
+ # <drift-stdout-file>: path to a file containing the captured stdout of
14
+ # `reconcile-readme.sh` (one structured drift line per row — `DRIFT`,
15
+ # `MISSING`, `STALE`, or `MISMATCH`).
16
+ #
17
+ # <problems-dir>: defaults to ./docs/problems. Used by `git status
18
+ # --porcelain` to scope the staged-rename probe.
19
+ #
20
+ # Output (stdout, single classification line):
21
+ # INLINE_REFRESH covered=<N> — every drift ID is the destination
22
+ # of a staged rename in the working
23
+ # tree; defer to in-flow P094/P062
24
+ # refresh per ADR-014.
25
+ # HALT_ROUTE_RECONCILE uncovered=<N> — at least one drift ID is NOT
26
+ # covered by a working-tree rename;
27
+ # committed cross-session drift OR
28
+ # mixed; route to
29
+ # /wr-itil:reconcile-readme.
30
+ #
31
+ # Exit codes:
32
+ # 0 = INLINE_REFRESH
33
+ # 1 = HALT_ROUTE_RECONCILE
34
+ # 2 = parse error (drift-stdout-file missing or empty)
35
+ #
36
+ # @problem P149 — manage-problem Step 0 reconcile halt-on-drift directive
37
+ # doesn't distinguish uncommitted-rename-rooted drift from
38
+ # committed cross-session drift; this script is the
39
+ # detection mechanism that makes the carve-out behavioural.
40
+ # @adr ADR-014 (single-commit grain — the carve-out preserves it for the
41
+ # in-flow path while keeping cross-session drift safety)
42
+ # @adr ADR-013 Rule 6 (AFK fail-safe — committed drift still routes to
43
+ # /wr-itil:reconcile-readme; only the inline path
44
+ # is added)
45
+ # @adr ADR-038 (progressive disclosure — output is a single terse line)
46
+ # @jtbd JTBD-006 (Progress the Backlog While I'm Away — AFK loop continuity)
47
+ # @jtbd JTBD-001 (Enforce Governance Without Slowing Down — single-commit grain)
48
+
49
+ set -uo pipefail
50
+
51
+ DRIFT_FILE="${1:-}"
52
+ PROBLEMS_DIR="${2:-docs/problems}"
53
+
54
+ if [ -z "$DRIFT_FILE" ]; then
55
+ echo "USAGE: classify-readme-drift.sh <drift-stdout-file> [<problems-dir>]" >&2
56
+ exit 2
57
+ fi
58
+
59
+ if [ ! -f "$DRIFT_FILE" ]; then
60
+ echo "PARSE_ERROR: drift-stdout-file not found: $DRIFT_FILE" >&2
61
+ exit 2
62
+ fi
63
+
64
+ # ── Extract drifting IDs from the script's structured output ────────────────
65
+ # Each line is one of:
66
+ # DRIFT P<NNN> wsjf-rankings: ...
67
+ # MISSING P<NNN> wsjf-rankings: ...
68
+ # MISSING P<NNN> verification-queue: ...
69
+ # STALE P<NNN> verification-queue: ...
70
+ # MISMATCH P<NNN> closed: ...
71
+ DRIFT_IDS="$(grep -oE 'P[0-9]{3}' "$DRIFT_FILE" 2>/dev/null | sort -u)"
72
+
73
+ if [ -z "$DRIFT_IDS" ]; then
74
+ echo "PARSE_ERROR: drift-stdout-file empty (no P<NNN> tokens): $DRIFT_FILE" >&2
75
+ exit 2
76
+ fi
77
+
78
+ # ── Build set of IDs covered by staged renames in the working tree ──────────
79
+ # `git status --porcelain` v1 emits rename lines as:
80
+ # R <old-path> -> <new-path>
81
+ # RM <old-path> -> <new-path> (rename + unstaged modification)
82
+ # We match the destination path's ticket ID — the post-rename status is what
83
+ # the in-flow P094/P062 refresh will reconcile in the upcoming commit.
84
+ RENAMED_IDS=""
85
+ if git rev-parse --git-dir >/dev/null 2>&1; then
86
+ RENAMED_IDS="$(
87
+ git status --porcelain "$PROBLEMS_DIR" 2>/dev/null \
88
+ | awk '/^R/' \
89
+ | sed 's|.*-> ||' \
90
+ | sed "s|^${PROBLEMS_DIR}/||" \
91
+ | grep -oE '^[0-9]{3}' \
92
+ | awk '{ printf "P%s\n", $0 }' \
93
+ | sort -u
94
+ )"
95
+ fi
96
+
97
+ # ── Cross-reference each drift ID against the renamed set ───────────────────
98
+ COVERED=0
99
+ UNCOVERED=0
100
+ while IFS= read -r id; do
101
+ [ -z "$id" ] && continue
102
+ if [ -n "$RENAMED_IDS" ] && printf '%s\n' "$RENAMED_IDS" | grep -qx "$id"; then
103
+ COVERED=$((COVERED + 1))
104
+ else
105
+ UNCOVERED=$((UNCOVERED + 1))
106
+ fi
107
+ done <<< "$DRIFT_IDS"
108
+
109
+ if [ "$UNCOVERED" -gt 0 ]; then
110
+ printf 'HALT_ROUTE_RECONCILE uncovered=%d\n' "$UNCOVERED"
111
+ exit 1
112
+ fi
113
+
114
+ printf 'INLINE_REFRESH covered=%d\n' "$COVERED"
115
+ exit 0
@@ -0,0 +1,208 @@
1
+ #!/usr/bin/env bash
2
+ # packages/itil/scripts/reconcile-readme.sh
3
+ #
4
+ # Diagnose-only drift detector for docs/problems/README.md vs filesystem
5
+ # truth. Reads <problems-dir>/<NNN>-*.<status>.md, parses the README's
6
+ # WSJF Rankings + Verification Queue + Closed tables, and reports each
7
+ # disagreement.
8
+ #
9
+ # Usage:
10
+ # reconcile-readme.sh [<problems-dir>]
11
+ #
12
+ # Default <problems-dir> is ./docs/problems.
13
+ #
14
+ # Exit codes:
15
+ # 0 = clean (README matches filesystem)
16
+ # 1 = drift detected (structured diff to stdout)
17
+ # 2 = parse error (README missing or malformed)
18
+ #
19
+ # Output format on drift (one line per drift entry, ≤ 150 bytes per
20
+ # ADR-038 progressive-disclosure budget):
21
+ # DRIFT <ID> wsjf-rankings: claims=<status> actual=<status>
22
+ # MISSING <ID> wsjf-rankings: actual=<status> file=<basename>
23
+ # STALE <ID> verification-queue: actual=<status>
24
+ # MISMATCH <ID> closed: actual=<status>
25
+ #
26
+ # Read-only — does NOT mutate the README. The /wr-itil:reconcile-readme
27
+ # skill applies edits with narrative-aware preservation; this script's
28
+ # only job is to report ground truth.
29
+ #
30
+ # @problem P118
31
+ # @adr ADR-014 (Reconciliation as preflight robustness layer)
32
+ # @adr ADR-022 (Verification Pending lifecycle excludes from WSJF Rankings)
33
+ # @adr ADR-038 (Progressive disclosure — per-row byte budget)
34
+
35
+ set -uo pipefail
36
+
37
+ PROBLEMS_DIR="${1:-docs/problems}"
38
+ README="${PROBLEMS_DIR}/README.md"
39
+
40
+ # ── Pre-checks ──────────────────────────────────────────────────────────────
41
+
42
+ if [ ! -f "$README" ]; then
43
+ echo "PARSE_ERROR: README not found at ${README}" >&2
44
+ exit 2
45
+ fi
46
+
47
+ if ! grep -q '^## WSJF Rankings' "$README"; then
48
+ echo "PARSE_ERROR: '## WSJF Rankings' header missing in ${README}" >&2
49
+ exit 2
50
+ fi
51
+
52
+ # ── Build filesystem truth: ID → status ─────────────────────────────────────
53
+
54
+ declare -A FS_STATUS
55
+ shopt -s nullglob
56
+ for f in "$PROBLEMS_DIR"/[0-9][0-9][0-9]-*.open.md \
57
+ "$PROBLEMS_DIR"/[0-9][0-9][0-9]-*.known-error.md \
58
+ "$PROBLEMS_DIR"/[0-9][0-9][0-9]-*.verifying.md \
59
+ "$PROBLEMS_DIR"/[0-9][0-9][0-9]-*.closed.md \
60
+ "$PROBLEMS_DIR"/[0-9][0-9][0-9]-*.parked.md; do
61
+ base="$(basename "$f")"
62
+ num="${base%%-*}"
63
+ id="P${num}"
64
+ # `ticket_status` (not bash `status`) — zsh has `$status` as a read-only
65
+ # built-in mapping to `$?`. Defensive rename per P133 even though this
66
+ # script's `#!/usr/bin/env bash` shebang means it never runs under zsh.
67
+ case "$base" in
68
+ *.open.md) ticket_status="open" ;;
69
+ *.known-error.md) ticket_status="known-error" ;;
70
+ *.verifying.md) ticket_status="verifying" ;;
71
+ *.closed.md) ticket_status="closed" ;;
72
+ *.parked.md) ticket_status="parked" ;;
73
+ *) continue ;;
74
+ esac
75
+ FS_STATUS["$id"]="$ticket_status"
76
+ done
77
+ shopt -u nullglob
78
+
79
+ # ── Parse README sections into ID buckets ───────────────────────────────────
80
+ # We use the section-header line numbers to slice the file into ranges.
81
+
82
+ WSJF_START=$(grep -n '^## WSJF Rankings' "$README" | head -1 | cut -d: -f1)
83
+ VQ_START=$(grep -n '^## Verification Queue' "$README" | head -1 | cut -d: -f1)
84
+ CLOSED_START=$(grep -n '^## Closed' "$README" | head -1 | cut -d: -f1)
85
+ PARKED_START=$(grep -n '^## Parked' "$README" | head -1 | cut -d: -f1)
86
+ END_LINE=$(wc -l < "$README")
87
+
88
+ # Sentinel each end with the next section start (or EOF).
89
+ WSJF_END=${VQ_START:-${CLOSED_START:-${PARKED_START:-$END_LINE}}}
90
+ VQ_END=${CLOSED_START:-${PARKED_START:-$END_LINE}}
91
+ CLOSED_END=${PARKED_START:-$END_LINE}
92
+ PARKED_END=$END_LINE
93
+
94
+ # Extract IDs claimed by each section. Only data rows of the form
95
+ # | ... | P<NNN> | ... |
96
+ # count; header + separator rows are skipped naturally because they
97
+ # do not contain a P<NNN> token in the second column.
98
+
99
+ extract_section_ids() {
100
+ local start="$1" end="$2"
101
+ [ -z "$start" ] && return 0
102
+ sed -n "${start},${end}p" "$README" \
103
+ | grep -oE '\| *P[0-9]{3} *\|' \
104
+ | grep -oE 'P[0-9]{3}' \
105
+ | sort -u
106
+ }
107
+
108
+ README_WSJF_IDS="$(extract_section_ids "$WSJF_START" "$WSJF_END")"
109
+ README_VQ_IDS="$(extract_section_ids "$VQ_START" "$VQ_END")"
110
+ README_CLOSED_IDS="$(extract_section_ids "$CLOSED_START" "$CLOSED_END")"
111
+ README_PARKED_IDS="$(extract_section_ids "$PARKED_START" "$PARKED_END")"
112
+
113
+ # ── Diff ─────────────────────────────────────────────────────────────────────
114
+
115
+ DRIFT_LINES=()
116
+
117
+ # (1) Each ID listed in WSJF Rankings must be .open.md or .known-error.md
118
+ # on disk. .verifying.md → drift (belongs in VQ); .closed.md → drift;
119
+ # .parked.md → drift; missing → drift.
120
+ while read -r id; do
121
+ [ -z "$id" ] && continue
122
+ actual="${FS_STATUS[$id]:-missing}"
123
+ case "$actual" in
124
+ open|known-error)
125
+ : # ok
126
+ ;;
127
+ *)
128
+ DRIFT_LINES+=("DRIFT ${id} wsjf-rankings: claims=open actual=${actual}")
129
+ ;;
130
+ esac
131
+ done <<< "$README_WSJF_IDS"
132
+
133
+ # (2) Each ID listed in Verification Queue must be .verifying.md on disk.
134
+ # .closed.md → STALE (drift class P062 closure didn't refresh);
135
+ # .open.md / .known-error.md → STALE; missing → STALE.
136
+ while read -r id; do
137
+ [ -z "$id" ] && continue
138
+ actual="${FS_STATUS[$id]:-missing}"
139
+ case "$actual" in
140
+ verifying)
141
+ : # ok
142
+ ;;
143
+ *)
144
+ DRIFT_LINES+=("STALE ${id} verification-queue: actual=${actual}")
145
+ ;;
146
+ esac
147
+ done <<< "$README_VQ_IDS"
148
+
149
+ # (3) Each ID listed in Closed section must be .closed.md on disk.
150
+ while read -r id; do
151
+ [ -z "$id" ] && continue
152
+ actual="${FS_STATUS[$id]:-missing}"
153
+ case "$actual" in
154
+ closed)
155
+ : # ok
156
+ ;;
157
+ *)
158
+ DRIFT_LINES+=("MISMATCH ${id} closed: actual=${actual}")
159
+ ;;
160
+ esac
161
+ done <<< "$README_CLOSED_IDS"
162
+
163
+ # (4) Each .open.md / .known-error.md file on disk must appear in WSJF
164
+ # Rankings. Build a lookup set for quick membership tests.
165
+ declare -A IN_WSJF
166
+ while read -r id; do
167
+ [ -z "$id" ] && continue
168
+ IN_WSJF["$id"]=1
169
+ done <<< "$README_WSJF_IDS"
170
+
171
+ declare -A IN_VQ
172
+ while read -r id; do
173
+ [ -z "$id" ] && continue
174
+ IN_VQ["$id"]=1
175
+ done <<< "$README_VQ_IDS"
176
+
177
+ for id in "${!FS_STATUS[@]}"; do
178
+ ticket_status="${FS_STATUS[$id]}"
179
+ case "$ticket_status" in
180
+ open|known-error)
181
+ if [ -z "${IN_WSJF[$id]:-}" ]; then
182
+ DRIFT_LINES+=("MISSING ${id} wsjf-rankings: actual=${ticket_status}")
183
+ fi
184
+ ;;
185
+ verifying)
186
+ if [ -z "${IN_VQ[$id]:-}" ]; then
187
+ DRIFT_LINES+=("MISSING ${id} verification-queue: actual=${ticket_status}")
188
+ fi
189
+ ;;
190
+ # closed and parked: not required to appear in their respective
191
+ # sections (Closed is curated narrative; Parked is exhaustive but
192
+ # an absence is a soft drift not flagged at this layer).
193
+ esac
194
+ done
195
+
196
+ # ── Report ──────────────────────────────────────────────────────────────────
197
+
198
+ if [ ${#DRIFT_LINES[@]} -eq 0 ]; then
199
+ exit 0
200
+ fi
201
+
202
+ # Sort for stable output (ID order).
203
+ IFS=$'\n' sorted=($(printf '%s\n' "${DRIFT_LINES[@]}" | sort))
204
+ unset IFS
205
+ for line in "${sorted[@]}"; do
206
+ printf '%s\n' "$line"
207
+ done
208
+ exit 1
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # @problem P134 — docs/problems/README.md line 3 narrative-blob accumulator
4
+ # bloat. Sibling to P099 (briefing tier 3) on a different surface. The
5
+ # "Last reviewed" line accumulates session-summary fragments unbounded;
6
+ # at ~62 KB it breaks the Read tool entirely (25K-token limit on the
7
+ # whole file regardless of offset/limit). P134 promotes the same
8
+ # advisory-triplet pattern P099 documented (advisory script + behavioural
9
+ # bats + ADR-040-tier-budget enforcement clause) to this surface.
10
+ #
11
+ # Contract: `check-problems-readme-budget.sh [<readme-path>]` is a
12
+ # diagnose-only advisory script. It reads `docs/problems/README.md`
13
+ # (or the supplied path), measures the byte size of line 3, and reports
14
+ # overflow when line 3 is at or above the configured threshold (default
15
+ # 5120 bytes per ADR-040 Tier 3 envelope; overridable via the
16
+ # `PROBLEMS_README_LINE3_MAX_BYTES` env var).
17
+ #
18
+ # Exit codes:
19
+ # 0 = always (advisory only — overflow is signal, not failure)
20
+ # 2 = parse error (README path missing or unreadable)
21
+ #
22
+ # Output format on overflow (one line, terse machine-readable per
23
+ # ADR-038 progressive-disclosure budget):
24
+ # OVER <readme-path> line=3 bytes=<N> threshold=<N>
25
+ #
26
+ # Output is empty (no lines) when line 3 is under the threshold.
27
+ #
28
+ # The script is read-only — it does NOT mutate the README. Truncation
29
+ # is owned by the per-operation refresh contracts in `manage-problem`
30
+ # Step 5 P094 + Step 7 P062 (and the sibling skills `transition-problem`,
31
+ # `transition-problems`, `review-problems`, `reconcile-readme`).
32
+ #
33
+ # @jtbd JTBD-001 (Enforce Governance Without Slowing Down — restores
34
+ # Read-tool affordance to the highest-traffic problems-management surface)
35
+ # @jtbd JTBD-006 (Progress the Backlog While I'm Away — every AFK iter's
36
+ # manage-problem call previously paid an awk/grep workaround tax)
37
+ # @jtbd JTBD-101 (Extend the Suite with Clear Patterns — re-applies P099's
38
+ # advisory-script + bats + ADR-tier-budget triplet to a new surface,
39
+ # exactly the reusable shape ADR-040 line 92 documents)
40
+ #
41
+ # Cross-reference:
42
+ # P134: docs/problems/134-docs-problems-readme-md-line-3-narrative-blob-accumulator-bloat-sibling-p099.*.md
43
+ # P099: docs/problems/099-briefing-md-grows-unbounded-via-run-retro-appends-violating-progressive-disclosure.*.md
44
+ # ADR-040 — Session-start briefing surface (Tier 3 budget; line 92's
45
+ # reusable-pattern note explicitly names "problems index" as a
46
+ # candidate surface for this triplet)
47
+ # ADR-038 — Progressive disclosure (per-row terse budget)
48
+ # ADR-013 Rule 6 — non-interactive fail-safe (advisory-only / exit 0)
49
+ # ADR-005 — Plugin testing strategy (script-level bats governance)
50
+
51
+ setup() {
52
+ SCRIPTS_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
53
+ SCRIPT="$SCRIPTS_DIR/check-problems-readme-budget.sh"
54
+ FIXTURE_DIR="$(mktemp -d)"
55
+ }
56
+
57
+ teardown() {
58
+ rm -rf "$FIXTURE_DIR"
59
+ }
60
+
61
+ # Helper: write a problems README fixture with the given line-3 byte size.
62
+ # Lines 1, 2 are short header; line 3 is the synthetic "Last reviewed"
63
+ # blob padded to target_bytes; lines 4+ contain a short stub so the
64
+ # multi-line shape mirrors the production README.
65
+ write_problems_readme() {
66
+ local path="$1"
67
+ local target_bytes="$2"
68
+ : > "$path"
69
+ printf '# Problem Backlog\n\n' >> "$path"
70
+ if [ "$target_bytes" -gt 0 ]; then
71
+ # Build a single line of exactly target_bytes bytes (no trailing
72
+ # newline before the byte budget completes). The newline at the
73
+ # end is part of the file structure but not counted in line 3's
74
+ # byte size — `awk 'NR==3' | wc -c` would include it; the script
75
+ # under test must strip it to match the threshold semantics.
76
+ local prefix='> Last reviewed: '
77
+ local prefix_size=${#prefix}
78
+ local body_target=$(( target_bytes - prefix_size ))
79
+ if [ "$body_target" -gt 0 ]; then
80
+ printf '%s' "$prefix" >> "$path"
81
+ # Append body_target dots — a single contiguous line.
82
+ printf '%.0s.' $(seq 1 "$body_target") >> "$path"
83
+ else
84
+ # target smaller than prefix — emit only the requested bytes
85
+ printf '%.0s.' $(seq 1 "$target_bytes") >> "$path"
86
+ fi
87
+ printf '\n' >> "$path"
88
+ else
89
+ printf '\n' >> "$path"
90
+ fi
91
+ printf '\n## WSJF Rankings\n\n(stub)\n' >> "$path"
92
+ }
93
+
94
+ # ── Existence + executable ──────────────────────────────────────────────────
95
+
96
+ @test "check-problems-readme-budget: script exists" {
97
+ [ -f "$SCRIPT" ]
98
+ }
99
+
100
+ @test "check-problems-readme-budget: script is executable" {
101
+ [ -x "$SCRIPT" ]
102
+ }
103
+
104
+ # ── Default-threshold behaviour (5120 bytes per ADR-040 Tier 3 envelope) ────
105
+
106
+ @test "check-problems-readme-budget: line 3 well under threshold produces no output and exits 0" {
107
+ write_problems_readme "$FIXTURE_DIR/README.md" 1024
108
+ run "$SCRIPT" "$FIXTURE_DIR/README.md"
109
+ [ "$status" -eq 0 ]
110
+ [ -z "$output" ]
111
+ }
112
+
113
+ @test "check-problems-readme-budget: line 3 at exactly the default threshold emits OVER (>= boundary)" {
114
+ write_problems_readme "$FIXTURE_DIR/README.md" 5120
115
+ run "$SCRIPT" "$FIXTURE_DIR/README.md"
116
+ [ "$status" -eq 0 ]
117
+ echo "$output" | grep -E "^OVER .*README.md line=3 bytes=5120 threshold=5120$"
118
+ }
119
+
120
+ @test "check-problems-readme-budget: line 3 well over threshold emits OVER with bytes + threshold" {
121
+ write_problems_readme "$FIXTURE_DIR/README.md" 12000
122
+ run "$SCRIPT" "$FIXTURE_DIR/README.md"
123
+ [ "$status" -eq 0 ]
124
+ echo "$output" | grep -E "^OVER .*README.md line=3 bytes=12000 threshold=5120$"
125
+ }
126
+
127
+ @test "check-problems-readme-budget: short line 3 (single fragment ~600 bytes) produces no output" {
128
+ write_problems_readme "$FIXTURE_DIR/README.md" 600
129
+ run "$SCRIPT" "$FIXTURE_DIR/README.md"
130
+ [ "$status" -eq 0 ]
131
+ [ -z "$output" ]
132
+ }
133
+
134
+ # ── Configurable threshold via env var ──────────────────────────────────────
135
+
136
+ @test "check-problems-readme-budget: PROBLEMS_README_LINE3_MAX_BYTES env var overrides default" {
137
+ write_problems_readme "$FIXTURE_DIR/README.md" 3000
138
+ # Default 5120: under threshold, no output. With env var set to 2000:
139
+ # over threshold, expect OVER line.
140
+ PROBLEMS_README_LINE3_MAX_BYTES=2000 run "$SCRIPT" "$FIXTURE_DIR/README.md"
141
+ [ "$status" -eq 0 ]
142
+ echo "$output" | grep -E "^OVER .*README.md line=3 bytes=3000 threshold=2000$"
143
+ }
144
+
145
+ @test "check-problems-readme-budget: env var threshold of 0 emits OVER for non-empty line 3 (sanity)" {
146
+ write_problems_readme "$FIXTURE_DIR/README.md" 100
147
+ PROBLEMS_README_LINE3_MAX_BYTES=0 run "$SCRIPT" "$FIXTURE_DIR/README.md"
148
+ [ "$status" -eq 0 ]
149
+ echo "$output" | grep -E "^OVER .*README.md line=3 bytes=100 threshold=0$"
150
+ }
151
+
152
+ # ── Argument and error handling ─────────────────────────────────────────────
153
+
154
+ @test "check-problems-readme-budget: defaults to docs/problems/README.md when no arg provided" {
155
+ cd "$FIXTURE_DIR"
156
+ mkdir -p docs/problems
157
+ write_problems_readme docs/problems/README.md 8000
158
+ run "$SCRIPT"
159
+ [ "$status" -eq 0 ]
160
+ echo "$output" | grep -E "^OVER docs/problems/README.md line=3 bytes=8000 threshold=5120$"
161
+ }
162
+
163
+ @test "check-problems-readme-budget: missing README path exits 2 with parse error on stderr" {
164
+ run "$SCRIPT" "$FIXTURE_DIR/does-not-exist/README.md"
165
+ [ "$status" -eq 2 ]
166
+ echo "$output" | grep -iE "not found|missing|does not exist"
167
+ }
168
+
169
+ @test "check-problems-readme-budget: file with no line 3 (only 2 lines) exits 0 with no output" {
170
+ printf '# Problem Backlog\n\n' > "$FIXTURE_DIR/README.md"
171
+ run "$SCRIPT" "$FIXTURE_DIR/README.md"
172
+ [ "$status" -eq 0 ]
173
+ [ -z "$output" ]
174
+ }
175
+
176
+ @test "check-problems-readme-budget: file with empty line 3 exits 0 with no output" {
177
+ printf '# Problem Backlog\n\n\n## WSJF Rankings\n\n(stub)\n' > "$FIXTURE_DIR/README.md"
178
+ run "$SCRIPT" "$FIXTURE_DIR/README.md"
179
+ [ "$status" -eq 0 ]
180
+ [ -z "$output" ]
181
+ }
182
+
183
+ # ── Read-only contract ──────────────────────────────────────────────────────
184
+
185
+ @test "check-problems-readme-budget: script does not mutate the README under audit" {
186
+ write_problems_readme "$FIXTURE_DIR/README.md" 12000
187
+ pre_hash=$(shasum -a 256 "$FIXTURE_DIR/README.md" | awk '{print $1}')
188
+ run "$SCRIPT" "$FIXTURE_DIR/README.md"
189
+ [ "$status" -eq 0 ]
190
+ post_hash=$(shasum -a 256 "$FIXTURE_DIR/README.md" | awk '{print $1}')
191
+ [ "$pre_hash" = "$post_hash" ]
192
+ }