@windyroad/itil 0.61.0 → 0.61.1-preview.1037

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.
@@ -497,5 +497,5 @@
497
497
  }
498
498
  },
499
499
  "name": "wr-itil",
500
- "version": "0.61.0"
500
+ "version": "0.61.1"
501
501
  }
@@ -19,15 +19,43 @@
19
19
  # encodings in one filter:
20
20
  # - markdown: `human-oversight:` / `oversight-hash:` frontmatter lines
21
21
  # - HTML: <meta name="human-oversight" ...> / <meta name="oversight-hash" ...>
22
+ # THE single definition of what the fingerprint ignores. Reads stdin, writes the
23
+ # normalised stream. Both hash functions route through this and neither carries
24
+ # its own copy — that duplication is why the P474 `**Status**:` mirror had to be
25
+ # removed from two places and why a third copy could have been missed entirely
26
+ # (RFC-059 / STORY-055).
27
+ #
28
+ # Excludes the marker lines plus the lifecycle `status` key in both encodings, and
29
+ # normalises lifecycle PROGRESS — acceptance-criterion ticks and slice
30
+ # `data-status` — so ONLY a SUBSTANCE change re-opens ratification. Ticking a
31
+ # criterion or advancing status is progress, not a revision of what the human
32
+ # ratified; the value statement, criterion TEXT and structure all still drift.
33
+ #
34
+ # Editing this filter changes ADR-090's Decision Outcome, not merely a mechanism.
35
+ # It is the surface the 2026-07-03 narrowing changed while going unrecorded for
36
+ # three weeks; a change here needs an ADR-090 amendment. The pattern is a
37
+ # LITERAL on purpose: built from a variable, an expansion that ever yielded empty
38
+ # would make `grep -vE ''` suppress every line and hash the empty stream to a
39
+ # constant, after which one artefact's stored hash would validate against ANY
40
+ # content — and `itil-no-implement-draft-gate` sources this lib under
41
+ # `2>/dev/null || exit 0`, so that failure would remove the gate silently.
42
+ _oversight_filter() {
43
+ grep -vE '^(human-oversight|oversight-hash|oversight-basis|status):|<meta[^>]*name="(human-oversight|oversight-hash|oversight-basis|status)"' \
44
+ | sed -E 's/- \[[ xX]\]/- [ ]/g; s/data-status="[^"]*"/data-status=""/g'
45
+ }
46
+
47
+ # The excluded key set, readable by tests and the corpus lint. Kept in agreement
48
+ # with the literal above by a bidirectional test rather than by construction, for
49
+ # the reason in that comment. One key per line.
50
+ oversight_excluded_keys() {
51
+ printf '%s\n' human-oversight oversight-hash oversight-basis status
52
+ }
53
+
22
54
  oversight_content_hash() {
23
- # Exclude the marker + lifecycle-`status` lines, and normalize lifecycle-PROGRESS
24
- # state acceptance-criterion checkbox ticks and slice `data-status`so that
25
- # ONLY a SUBSTANCE change re-opens ratification. Ticking a criterion or advancing
26
- # status/slice-progress is progress, not a change to what the user ratified; the
27
- # value statement, criterion TEXT, and structure still drift the hash.
28
- grep -vE '^(human-oversight|oversight-hash|oversight-basis|status):|<meta[^>]*name="(human-oversight|oversight-hash|oversight-basis|status)"' "$1" \
29
- | sed -E 's/- \[[ xX]\]/- [ ]/g; s/data-status="[^"]*"/data-status=""/g' \
30
- | shasum -a 256 | awk '{print $1}'
55
+ # Input path: the file is fed to the filter directly, so trailing blank lines
56
+ # are PRESERVED. Deliberately NOT unified with the map variant below see the
57
+ # note there. Changing this changes every stored fingerprint.
58
+ _oversight_filter < "$1" | shasum -a 256 | awk '{print $1}'
31
59
  }
32
60
 
33
61
  # Hash a story MAP's content while EXCLUDING the single-line card elements whose
@@ -52,10 +80,16 @@ oversight_content_hash_excluding_stories() {
52
80
  # Anchored on the closing quote so STORY-05 cannot strip STORY-054's card.
53
81
  filtered="$(printf '%s\n' "$filtered" | grep -vF "data-story-id=\"${id}\"" || true)"
54
82
  done
55
- printf '%s\n' "$filtered" \
56
- | grep -vE '^(human-oversight|oversight-hash|oversight-basis|status):|<meta[^>]*name="(human-oversight|oversight-hash|oversight-basis|status)"' \
57
- | sed -E 's/- \[[ xX]\]/- [ ]/g; s/data-status="[^"]*"/data-status=""/g' \
58
- | shasum -a 256 | awk '{print $1}'
83
+ # Input path: `$(cat)` above stripped ALL trailing newlines and this `printf`
84
+ # re-adds exactly one, so trailing blank lines are COLLAPSED here where
85
+ # `oversight_content_hash` preserves them. That divergence is pre-existing and
86
+ # is preserved deliberately unifying the two input paths would silently change
87
+ # one function's hash and un-ratify every stored fingerprint at once. A
88
+ # consequence worth knowing: with zero ids this does NOT equal
89
+ # `oversight_content_hash` for an artefact with trailing blank lines, which is
90
+ # recorded as its own P474 task because it makes ADR-101's map leg
91
+ # unsatisfiable for such a map even with the right card excluded.
92
+ printf '%s\n' "$filtered" | _oversight_filter | shasum -a 256 | awk '{print $1}'
59
93
  }
60
94
 
61
95
  # True (0) if this artefact's `confirmed` marker was written by the ADR-101 AFK
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windyroad/itil",
3
- "version": "0.61.0",
3
+ "version": "0.61.1-preview.1037",
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"
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env bats
2
+ # P474 / STORY-055 — corpus lint: no artefact may mirror an excluded key.
3
+ #
4
+ # This is a LINT, not a behavioural test, and the distinction is deliberate rather
5
+ # than an ADR-052 exception. ADR-052's forbidden class is structural assertion on
6
+ # document content *as a proxy for behaviour*; all its documented failure modes
7
+ # are proxy failures. Here the content IS the deliverable — "no live artefact
8
+ # carries a mirror" is a property of data, and data has no behaviour to assert.
9
+ # So it homes under ADR-005's script/data-validation authority, which ADR-052
10
+ # explicitly leaves intact.
11
+ #
12
+ # WHY it exists: P474's mirror audit was a one-time inspection, and a one-time
13
+ # inspection has no cadence. The fingerprint excludes four frontmatter keys; if
14
+ # any of them acquires a body mirror, accepting a story silently drops its own
15
+ # ratification and the no-implement gate then denies its own implementing commit.
16
+ # That already happened once with `status`.
17
+ #
18
+ # The behavioural half lives in the last test: it covers a mirror of ANY excluded
19
+ # key in ANY shape, including an HTML meta mirror, which the lint's line-anchored
20
+ # bold-key pattern structurally cannot reach.
21
+ #
22
+ # Two things this lint does NOT catch, recorded rather than implied:
23
+ # 1. The story-map `href` leg (open P474 task) — a card links
24
+ # `../../stories/<state>/…`, so the lifecycle directory sits inside the map's
25
+ # hashed content while the card's `data-status` is normalised out. No
26
+ # bold-key pattern can see that.
27
+ # 2. A mirror written with leading whitespace or inside a list item — the cost
28
+ # of line-anchoring, taken so no exclusion set is needed.
29
+ #
30
+ # @problem P474 @adr ADR-090 ADR-005 ADR-052 @story STORY-055
31
+
32
+ setup() {
33
+ REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../../../.." && pwd)"
34
+ LIB="${REPO_ROOT}/packages/itil/lib/story-oversight.sh"
35
+ # shellcheck source=/dev/null
36
+ source "$LIB"
37
+ STORIES="$REPO_ROOT/docs/stories"
38
+ MAPS="$REPO_ROOT/docs/story-maps"
39
+ TMPD="$(mktemp -d)"
40
+ }
41
+ teardown() { rm -rf "$TMPD"; }
42
+
43
+ # Build the pattern from the lib's own accessor, so a key added to the exclusion
44
+ # set is covered without editing this file. Case-INSENSITIVE is load-bearing: the
45
+ # accessor holds lowercase keys (`status`) while every real mirror is Title-Case
46
+ # (`**Status**:`), so a case-sensitive pattern built from the accessor matches
47
+ # nothing on arrival AND stays green when a mirror is reintroduced.
48
+ mirror_pattern() {
49
+ local alt; alt="$(oversight_excluded_keys | paste -sd'|' -)"
50
+ printf '^\\*\\*(%s)\\*\\*[[:space:]]*:' "$alt"
51
+ }
52
+
53
+ # Line-anchored on purpose. Unanchored, this matches four lines of legitimate
54
+ # prose *about* the defect (the stories README, two lines in STORY-054, and
55
+ # STORY-055's own problem trace), which would redden on arrival and require an
56
+ # exclusion list growing with every future story that discusses a mirror.
57
+ scan() {
58
+ grep -rniE "$(mirror_pattern)" "$@" 2>/dev/null || true
59
+ }
60
+
61
+ @test "lint: the excluded-key accessor is non-empty and the pattern is well-formed" {
62
+ # Guard against a silently-empty pattern — the whole lint would go vacuous.
63
+ local keys; keys="$(oversight_excluded_keys)"
64
+ [ -n "$keys" ]
65
+ [ "$(printf '%s\n' "$keys" | wc -l | tr -d ' ')" -ge 4 ]
66
+ [[ "$(mirror_pattern)" == *'status'* ]]
67
+ }
68
+
69
+ @test "lint: search roots exist and hold artefacts — fails loudly, never vacuously" {
70
+ # A renamed or adopter-relative path must fail here rather than let the scan
71
+ # below pass by matching nothing. This is the shape that reddened main once.
72
+ [ -d "$STORIES" ]
73
+ [ -d "$MAPS" ]
74
+ local n
75
+ n="$(find "$STORIES" -name 'STORY-*.md' -type f 2>/dev/null | wc -l | tr -d ' ')"
76
+ [ "$n" -ge 1 ] || { echo "no STORY-*.md under $STORIES — lint would be vacuous"; false; }
77
+ n="$(find "$MAPS" -name '*.html' -type f 2>/dev/null | wc -l | tr -d ' ')"
78
+ [ "$n" -ge 1 ] || { echo "no maps under $MAPS — lint would be vacuous"; false; }
79
+ }
80
+
81
+ @test "lint: no story artefact mirrors an excluded frontmatter key in its body" {
82
+ local hits
83
+ hits="$(scan "$STORIES"/*/STORY-*.md)"
84
+ [ -z "$hits" ] || {
85
+ printf 'body mirror of an excluded key found — accepting these stories will drop their ratification:\n%s\n' "$hits"
86
+ printf 'remove the line; run `wr-itil-migrate-story-status-mirror docs/stories` for an existing corpus.\n'
87
+ false
88
+ }
89
+ }
90
+
91
+ @test "lint: no story map mirrors an excluded frontmatter key in its body" {
92
+ # The map's own `<meta name="status">` is the key's HOME in the HTML encoding,
93
+ # not a mirror of it, and the lib's filter already excludes it — so the
94
+ # line-anchored bold-key pattern correctly does not flag it.
95
+ local hits
96
+ hits="$(scan "$MAPS"/*/*.html)"
97
+ [ -z "$hits" ] || {
98
+ printf 'body mirror of an excluded key found in a map:\n%s\n' "$hits"
99
+ false
100
+ }
101
+ }
102
+
103
+ @test "lint: RED on an injected mirror — proves the pattern actually fires" {
104
+ mkdir -p "$TMPD/stories/draft"
105
+ printf -- '---\nstatus: accepted\n---\n\n# STORY-999: x\n\n**Status**: accepted\n**Reported**: 2026-07-30\n' \
106
+ > "$TMPD/stories/draft/STORY-999-x.md"
107
+ local hits; hits="$(scan "$TMPD/stories"/*/STORY-*.md)"
108
+ [ -n "$hits" ] || { echo "lint failed to detect an injected line-start **Status**: mirror"; false; }
109
+ [[ "$hits" == *"STORY-999"* ]]
110
+ }
111
+
112
+ @test "lint: prose ABOUT a mirror is not flagged — that is what line-anchoring buys" {
113
+ mkdir -p "$TMPD/prose/draft"
114
+ printf -- '---\nstatus: accepted\n---\n\n# STORY-998: y\n\nThe `**Status**:` line used to be hashed.\n<!-- No **Status**: body line, deliberately. -->\n' \
115
+ > "$TMPD/prose/draft/STORY-998-y.md"
116
+ [ -z "$(scan "$TMPD/prose"/*/STORY-*.md)" ]
117
+ }
118
+
119
+ # The behavioural half. Covers a mirror of ANY excluded key in ANY shape,
120
+ # including the HTML meta form the lint's pattern cannot reach.
121
+ @test "behavioural: a mirror of any excluded key drifts a ratified artefact" {
122
+ local k
123
+ for k in $(oversight_excluded_keys); do
124
+ # Skip the marker keys themselves — those are excluded BY the marker write,
125
+ # so a "mirror" of them is what the filter is for.
126
+ case "$k" in human-oversight|oversight-hash|oversight-basis) continue ;; esac
127
+ local f="$TMPD/mirror-$k.md"
128
+ printf -- '---\n%s: draft\nhuman-oversight: confirmed\noversight-hash: P\n---\n\n# STORY-997\n\nbody\n' "$k" > "$f"
129
+ local h; h="$(oversight_content_hash "$f")"
130
+ sed -i.bak "s/oversight-hash: P/oversight-hash: $h/" "$f" && rm -f "$f.bak"
131
+ run is_story_map_ratified "$f"
132
+ [ "$status" -eq 0 ]
133
+ # Add a body mirror of that key, then advance BOTH copies the way a
134
+ # transition would. The frontmatter change alone is excluded; the body
135
+ # mirror is not — so ratification must drop, which is P474 exactly.
136
+ printf -- '**%s**: accepted\n' "$k" >> "$f"
137
+ h="$(oversight_content_hash "$f")"
138
+ sed -i.bak "s/^oversight-hash: .*/oversight-hash: $h/" "$f" && rm -f "$f.bak"
139
+ sed -i.bak "s/^${k}: draft/${k}: accepted/; s/^\\*\\*${k}\\*\\*: accepted/**${k}**: DIFFERENT/" "$f" && rm -f "$f.bak"
140
+ run is_story_map_ratified "$f"
141
+ [ "$status" -ne 0 ] || { echo "a body mirror of '$k' did not drift the hash"; false; }
142
+ done
143
+ }
@@ -86,3 +86,105 @@ teardown() { rm -rf "$TMPD"; }
86
86
  printf '<meta name="human-oversight" content="confirmed">\n<meta name="oversight-hash" content="%s">\n<h1>map</h1>\n' "$h" > "$TMPD/m.html"
87
87
  run is_story_map_ratified "$TMPD/m.html"; [ "$status" -eq 0 ]
88
88
  }
89
+
90
+ # --- P474 / STORY-055: the extraction must not move a single hash --------------
91
+ #
92
+ # `oversight_content_hash` and `oversight_content_hash_excluding_stories` shared a
93
+ # verbatim grep|sed filter, which is why the P474 mirror had to be removed twice
94
+ # and why a third copy could have been missed. The filter is now extracted.
95
+ #
96
+ # The extraction boundary is the whole risk. The two functions' INPUT paths are
97
+ # NOT equivalent and must stay that way: one passes the file to grep directly,
98
+ # the other round-trips through $(cat) — which strips all trailing newlines —
99
+ # then printf '%s\n' re-adds exactly one. So trailing blank lines are preserved
100
+ # by the first and collapsed by the second. Extract the filter only; unify the
101
+ # input paths "for symmetry" and the hash silently changes for any artefact with
102
+ # anomalous trailing whitespace, un-ratifying every stored fingerprint at once.
103
+ #
104
+ # `_ref_*` below are VERBATIM copies of the pre-extraction implementation. They
105
+ # exist to be compared against, so do NOT refactor them to call the new helper —
106
+ # that would make the equivalence assertions circular and worthless.
107
+
108
+ _ref_hash() {
109
+ grep -vE '^(human-oversight|oversight-hash|oversight-basis|status):|<meta[^>]*name="(human-oversight|oversight-hash|oversight-basis|status)"' "$1" \
110
+ | sed -E 's/- \[[ xX]\]/- [ ]/g; s/data-status="[^"]*"/data-status=""/g' \
111
+ | shasum -a 256 | awk '{print $1}'
112
+ }
113
+
114
+ _ref_hash_excluding() {
115
+ local f="$1"; shift
116
+ local filtered id
117
+ filtered="$(cat "$f")"
118
+ for id in "$@"; do
119
+ [ -n "$id" ] || continue
120
+ filtered="$(printf '%s\n' "$filtered" | grep -vF "data-story-id=\"${id}\"" || true)"
121
+ done
122
+ printf '%s\n' "$filtered" \
123
+ | grep -vE '^(human-oversight|oversight-hash|oversight-basis|status):|<meta[^>]*name="(human-oversight|oversight-hash|oversight-basis|status)"' \
124
+ | sed -E 's/- \[[ xX]\]/- [ ]/g; s/data-status="[^"]*"/data-status=""/g' \
125
+ | shasum -a 256 | awk '{print $1}'
126
+ }
127
+
128
+ @test "extraction: byte-identical to the frozen reference across edge shapes" {
129
+ # No final newline.
130
+ printf -- '---\nstatus: draft\n---\nbody' > "$TMPD/a"
131
+ # Multiple trailing blank lines — the shape the input-path divergence hinges on.
132
+ printf -- '---\nstatus: draft\n---\nbody\n\n\n\n' > "$TMPD/b"
133
+ # CRLF.
134
+ printf -- '---\r\nstatus: draft\r\n---\r\nbody\r\n' > "$TMPD/c"
135
+ # Empty file.
136
+ : > "$TMPD/d"
137
+ # A criterion tick and a data-status, both normalised.
138
+ printf -- '- [x] done\n<a data-status="done">x</a>\n' > "$TMPD/e"
139
+ # Every excluded key present, in both encodings.
140
+ printf -- 'human-oversight: confirmed\noversight-hash: deadbeef\noversight-basis: pure-decomposition\nstatus: accepted\n<meta name="status" content="draft">\nbody\n' > "$TMPD/f"
141
+ # Only a newline.
142
+ printf '\n' > "$TMPD/g"
143
+
144
+ for n in a b c d e f g; do
145
+ [ "$(oversight_content_hash "$TMPD/$n")" = "$(_ref_hash "$TMPD/$n")" ] || {
146
+ echo "oversight_content_hash diverged on fixture $n"; false
147
+ }
148
+ done
149
+ }
150
+
151
+ @test "extraction: map variant byte-identical, including the id-exclusion loop" {
152
+ printf -- '<meta name="status" content="draft">\n<a data-story-id="STORY-05">short</a>\n<a data-story-id="STORY-054">long</a>\n<a class="slice" data-status="done">s</a>\nbody\n\n\n' \
153
+ > "$TMPD/m.html"
154
+ # Zero ids, one id, and the prefix-collision pair — STORY-05 must not strip
155
+ # STORY-054's card, which is what the closing-quote anchor buys.
156
+ [ "$(oversight_content_hash_excluding_stories "$TMPD/m.html")" = "$(_ref_hash_excluding "$TMPD/m.html")" ]
157
+ [ "$(oversight_content_hash_excluding_stories "$TMPD/m.html" STORY-054)" = "$(_ref_hash_excluding "$TMPD/m.html" STORY-054)" ]
158
+ [ "$(oversight_content_hash_excluding_stories "$TMPD/m.html" STORY-05)" = "$(_ref_hash_excluding "$TMPD/m.html" STORY-05)" ]
159
+ [ "$(oversight_content_hash_excluding_stories "$TMPD/m.html" STORY-05 STORY-054)" = "$(_ref_hash_excluding "$TMPD/m.html" STORY-05 STORY-054)" ]
160
+ # And the divergence itself is preserved rather than accidentally unified.
161
+ [ "$(oversight_content_hash_excluding_stories "$TMPD/m.html")" != "$(oversight_content_hash "$TMPD/m.html")" ]
162
+ }
163
+
164
+ # Golden hashes are pinned over FIXTURES, never over live artefacts. Pinning to
165
+ # the corpus would redden this suite on every later legitimate artefact edit —
166
+ # ADR-052's refactor-blocking failure mode. Their job is to catch the frozen
167
+ # reference itself rotting, which the equivalence cases above cannot.
168
+ @test "extraction: pinned golden hashes over a canonical fixture" {
169
+ printf -- '---\nstatus: draft\nhuman-oversight: confirmed\n---\n\n# STORY-900: canonical\n\n**Reported**: 2026-07-30\n\n- [ ] a criterion\n' \
170
+ > "$TMPD/golden.md"
171
+ [ "$(oversight_content_hash "$TMPD/golden.md")" = "1a18f2501f7092b64316a1566795e64860f90893c2b4d0d36cd9a783ada21414" ]
172
+ }
173
+
174
+ @test "extraction: the excluded-key accessor agrees with the operative pattern both ways" {
175
+ local keys; keys="$(oversight_excluded_keys)"
176
+ [ -n "$keys" ]
177
+ # Every accessor key appears as an alternation branch in the operative filter.
178
+ for k in $keys; do
179
+ grep -qF -- "$k" "$LIB" || { echo "accessor key '$k' absent from the lib pattern"; false; }
180
+ done
181
+ # And every branch in the operative pattern appears in the accessor — the
182
+ # direction that catches a key ADDED to the regex but not to the accessor,
183
+ # which would leave the corpus lint blind to it.
184
+ local branch
185
+ for branch in $(grep -oE '\^\(([a-z-]+\|)+[a-z-]+\):' "$LIB" | head -1 | tr -d '^():' | tr '|' ' '); do
186
+ printf '%s\n' "$keys" | grep -qx -- "$branch" || {
187
+ echo "pattern branch '$branch' missing from oversight_excluded_keys()"; false
188
+ }
189
+ done
190
+ }