@metasession.co/devaudit-cli 0.3.27 → 0.3.29

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metasession.co/devaudit-cli",
3
- "version": "0.3.27",
3
+ "version": "0.3.29",
4
4
  "description": "DevAudit CLI — installs, syncs, and operates the Metasession SDLC across consumer projects.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,7 +33,7 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "@clack/prompts": "^0.8.2",
36
- "@metasession.co/devaudit-plugin-sdk": "^0.3.27",
36
+ "@metasession.co/devaudit-plugin-sdk": "^0.3.29",
37
37
  "ajv": "^8.20.0",
38
38
  "commander": "^12.1.0",
39
39
  "consola": "^3.2.3",
@@ -34,6 +34,10 @@
34
34
 
35
35
  set -euo pipefail
36
36
 
37
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
38
+ # shellcheck source=markdown-table.sh
39
+ source "$SCRIPT_DIR/markdown-table.sh"
40
+
37
41
  REQ_ID="${1:-}"
38
42
  RELEASE_PR=""
39
43
  shift || true
@@ -89,21 +93,14 @@ find_release_ticket_file() {
89
93
  update_rtm_status() {
90
94
  local req_id="$1"
91
95
  local target_status="$2"
92
- local tmp_file
96
+ local tmp_file status_col
93
97
  tmp_file="$(mktemp)"
94
- if [ -f "$RTM" ] && grep -qE "^\| ${req_id} " "$RTM"; then
95
- awk -v req="$req_id" -v target_status="$target_status" '
96
- BEGIN { FS="|"; OFS="|"; statuscol=0 }
98
+ status_col="$(markdown_table_column_index_for_row \
99
+ "$RTM" "$req_id" Status REQ-ID/ID Status 2>/dev/null || true)"
100
+ if [ -n "$status_col" ]; then
101
+ awk -v req="$req_id" -v target_status="$target_status" -v statuscol="$status_col" '
102
+ BEGIN { FS="|"; OFS="|" }
97
103
  /\\\|/ { gsub(/\\\|/, "\001", $0) }
98
- {
99
- cand=0; idseen=0
100
- for (i=1; i<=NF; i++) {
101
- c=$i; gsub(/^[[:space:]]+|[[:space:]]+$/, "", c)
102
- if (c=="Status") cand=i
103
- if (c=="ID" || c=="REQ-ID" || c=="REQ ID" || c ~ /^Requirement/) idseen=1
104
- }
105
- if (cand>0 && idseen) statuscol=cand
106
- }
107
104
  $0 ~ ("^\\| " req " ") && statuscol>0 {
108
105
  cell=$statuscol
109
106
  note=""
@@ -39,6 +39,7 @@ done
39
39
  python3 - "$AUDIT_PATH" "$LOCK_PATH" "$EXCEPTIONS_PATH" "$OUTPUT_PATH" <<'PY'
40
40
  import json
41
41
  import os
42
+ import re
42
43
  import sys
43
44
  from datetime import date
44
45
 
@@ -144,8 +145,11 @@ for index, item in enumerate(exceptions, start=1):
144
145
  fail(f"exception {index} approvedAt cannot be in the future")
145
146
  if expires_at <= today:
146
147
  fail(f"exception {index} is expired on {item['expiresAt']}")
147
- if not item["remediationIssue"].startswith("https://github.com/"):
148
- fail(f"exception {index} remediationIssue must be a GitHub issue URL")
148
+ if re.fullmatch(r"https://github\.com/[^/]+/[^/]+/issues/[1-9][0-9]*", item["remediationIssue"]) is None:
149
+ fail(f"exception {index} remediationIssue must be an exact GitHub issue URL")
150
+ controls = item.get("compensatingControls")
151
+ if controls is not None and (not isinstance(controls, str) or not controls.strip()):
152
+ fail(f"exception {index} compensatingControls must be a non-empty string when present")
149
153
  key = (item["advisoryId"], item["dependencyPath"])
150
154
  if key in seen_exception_keys:
151
155
  fail(f"duplicate exception for {item['advisoryId']} at {item['dependencyPath']}")
@@ -157,75 +161,108 @@ accepted = []
157
161
  unresolved = []
158
162
  seen_advisories = set()
159
163
 
160
- for finding_name, finding in audit["vulnerabilities"].items():
164
+ vulnerabilities = audit["vulnerabilities"]
165
+
166
+
167
+ def concrete_advisories(finding_name, trail=()):
168
+ if finding_name in trail:
169
+ fail(f"audit vulnerability graph contains a cycle: {' -> '.join((*trail, finding_name))}")
170
+ finding = vulnerabilities.get(finding_name)
161
171
  if not isinstance(finding, dict):
162
- fail(f"audit vulnerability {finding_name} must be an object")
172
+ fail(f"audit vulnerability {finding_name} is missing or is not an object")
163
173
  via = finding.get("via", [])
164
174
  if not isinstance(via, list):
165
175
  fail(f"audit vulnerability {finding_name} has invalid via data")
166
- advisory_objects = [
167
- item for item in via
168
- if isinstance(item, dict) and item.get("severity") in ("high", "critical")
169
- ]
170
- if finding.get("severity") in ("high", "critical") and not advisory_objects:
171
- fail(f"high/critical audit vulnerability {finding_name} has no resolvable advisory object")
172
-
173
- nodes = finding.get("nodes", [])
174
- if advisory_objects and (not isinstance(nodes, list) or not nodes):
175
- fail(f"audit vulnerability {finding_name} has no installed package-lock node")
176
-
177
- for advisory in advisory_objects:
178
- package = advisory.get("dependency") or finding_name
179
- advisory_range = advisory.get("range")
180
- if not isinstance(package, str) or not package:
181
- fail(f"advisory in {finding_name} has no package name")
182
- if not isinstance(advisory_range, str) or not advisory_range:
183
- fail(f"advisory {advisory_id(advisory)} has no vulnerable range")
184
- identifier = advisory_id(advisory)
185
- for node_path in nodes:
186
- if not isinstance(node_path, str):
187
- fail(f"advisory {identifier} has invalid package-lock node")
188
- node = packages.get(node_path)
189
- if not isinstance(node, dict) or not isinstance(node.get("version"), str):
190
- fail(f"cannot resolve installed version for {node_path}")
191
- if package_name_from_path(node_path) != package:
192
- fail(f"audit package {package} does not match package-lock node {node_path}")
193
- version = node["version"]
194
- introduced_by = introducer_for(node_path, packages)
195
- decision_key = (identifier, node_path)
196
- if decision_key in seen_advisories:
197
- continue
198
- seen_advisories.add(decision_key)
199
- context = {
200
- "advisoryId": identifier,
201
- "severity": advisory["severity"],
202
- "package": package,
203
- "vulnerableRange": advisory_range,
204
- "vulnerableVersion": version,
205
- "dependencyPath": node_path,
206
- "introducedBy": introduced_by,
176
+
177
+ resolved = []
178
+ for index, item in enumerate(via):
179
+ if isinstance(item, str):
180
+ if item not in vulnerabilities:
181
+ fail(f"audit vulnerability {finding_name} references missing vulnerability {item}")
182
+ resolved.extend(concrete_advisories(item, (*trail, finding_name)))
183
+ elif isinstance(item, dict):
184
+ if item.get("severity") in ("high", "critical"):
185
+ resolved.append((finding_name, index, item))
186
+ else:
187
+ fail(f"audit vulnerability {finding_name} has invalid via entry")
188
+ return resolved
189
+
190
+
191
+ advisory_sources = {}
192
+ for finding_name, finding in vulnerabilities.items():
193
+ if not isinstance(finding, dict):
194
+ fail(f"audit vulnerability {finding_name} must be an object")
195
+ resolved = concrete_advisories(finding_name)
196
+ if finding.get("severity") in ("high", "critical") and not resolved:
197
+ fail(f"high/critical audit vulnerability {finding_name} has no reachable advisory object")
198
+ for source_name, source_index, advisory in resolved:
199
+ advisory_sources.setdefault((source_name, source_index), {
200
+ "advisory": advisory,
201
+ "affectedFindings": set(),
202
+ })["affectedFindings"].add(finding_name)
203
+
204
+ for (source_name, _source_index), source in advisory_sources.items():
205
+ advisory = source["advisory"]
206
+ source_finding = vulnerabilities[source_name]
207
+ nodes = source_finding.get("nodes", [])
208
+ if not isinstance(nodes, list) or not nodes:
209
+ fail(f"audit vulnerability {source_name} has no installed package-lock node")
210
+
211
+ package = advisory.get("dependency") or source_name
212
+ advisory_range = advisory.get("range")
213
+ if not isinstance(package, str) or not package:
214
+ fail(f"advisory in {source_name} has no package name")
215
+ if not isinstance(advisory_range, str) or not advisory_range:
216
+ fail(f"advisory {advisory_id(advisory)} has no vulnerable range")
217
+ identifier = advisory_id(advisory)
218
+ for node_path in nodes:
219
+ if not isinstance(node_path, str):
220
+ fail(f"advisory {identifier} has invalid package-lock node")
221
+ node = packages.get(node_path)
222
+ if not isinstance(node, dict) or not isinstance(node.get("version"), str):
223
+ fail(f"cannot resolve installed version for {node_path}")
224
+ if package_name_from_path(node_path) != package:
225
+ fail(f"audit package {package} does not match package-lock node {node_path}")
226
+ version = node["version"]
227
+ introduced_by = introducer_for(node_path, packages)
228
+ decision_key = (identifier, node_path)
229
+ if decision_key in seen_advisories:
230
+ continue
231
+ seen_advisories.add(decision_key)
232
+ context = {
233
+ "advisoryId": identifier,
234
+ "severity": advisory["severity"],
235
+ "package": package,
236
+ "vulnerableRange": advisory_range,
237
+ "vulnerableVersion": version,
238
+ "dependencyPath": node_path,
239
+ "introducedBy": introduced_by,
240
+ "affectedFindings": sorted(source["affectedFindings"]),
241
+ }
242
+ match = next((item for item in validated_exceptions if all(
243
+ item[field] == context[field]
244
+ for field in ("advisoryId", "package", "vulnerableRange", "vulnerableVersion", "dependencyPath", "introducedBy")
245
+ )), None)
246
+ if match is None:
247
+ unresolved.append(context)
248
+ print(
249
+ f"Unaccepted {context['severity']} risk: {identifier} "
250
+ f"({package}@{version} at {node_path})",
251
+ file=sys.stderr,
252
+ )
253
+ else:
254
+ acceptance = {
255
+ field: match[field]
256
+ for field in ("approvedAt", "expiresAt", "approvedBy", "reason", "remediationIssue")
207
257
  }
208
- match = next((item for item in validated_exceptions if all(
209
- item[field] == context[field]
210
- for field in ("advisoryId", "package", "vulnerableRange", "vulnerableVersion", "dependencyPath", "introducedBy")
211
- )), None)
212
- if match is None:
213
- unresolved.append(context)
214
- print(
215
- f"Unaccepted {context['severity']} risk: {identifier} "
216
- f"({package}@{version} at {node_path})",
217
- file=sys.stderr,
218
- )
219
- else:
220
- accepted.append({**context, "acceptance": {
221
- field: match[field]
222
- for field in ("approvedAt", "expiresAt", "approvedBy", "reason", "remediationIssue")
223
- }})
224
- print(
225
- f"Accepted temporary risk: {identifier} ({package}@{version}) "
226
- f"until {match['expiresAt']} by {match['approvedBy']}; "
227
- f"remediation {match['remediationIssue']}",
228
- )
258
+ if "compensatingControls" in match:
259
+ acceptance["compensatingControls"] = match["compensatingControls"]
260
+ accepted.append({**context, "acceptance": acceptance})
261
+ print(
262
+ f"Accepted temporary risk: {identifier} ({package}@{version}) "
263
+ f"until {match['expiresAt']} by {match['approvedBy']}; "
264
+ f"remediation {match['remediationIssue']}",
265
+ )
229
266
 
230
267
  result = {
231
268
  "schemaVersion": 1,
@@ -7,6 +7,9 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
7
7
  HELPER="$SCRIPT_DIR/evaluate-npm-audit.sh"
8
8
  WORK="$(mktemp -d)"
9
9
  trap 'rm -rf "$WORK"' EXIT
10
+ APPROVED_AT="$(date -u -d 'yesterday' +%Y-%m-%d)"
11
+ EXPIRES_AT="$(date -u -d '+30 days' +%Y-%m-%d)"
12
+ EXPIRED_AT="$(date -u -d 'yesterday' +%Y-%m-%d)"
10
13
 
11
14
  mkdir -p "$WORK/compliance/security"
12
15
 
@@ -37,6 +40,47 @@ write_empty_audit() {
37
40
  printf '{"vulnerabilities":{}}\n' > "$WORK/dependency-audit.json"
38
41
  }
39
42
 
43
+ write_meta_audit() {
44
+ cat > "$WORK/dependency-audit.json" <<'JSON'
45
+ {
46
+ "vulnerabilities": {
47
+ "eslint": {
48
+ "name": "eslint",
49
+ "severity": "high",
50
+ "via": ["@eslint/eslintrc"],
51
+ "nodes": ["node_modules/eslint"]
52
+ },
53
+ "@eslint/eslintrc": {
54
+ "name": "@eslint/eslintrc",
55
+ "severity": "high",
56
+ "via": ["minimatch"],
57
+ "nodes": ["node_modules/@eslint/eslintrc"]
58
+ },
59
+ "minimatch": {
60
+ "name": "minimatch",
61
+ "severity": "high",
62
+ "via": ["brace-expansion"],
63
+ "nodes": ["node_modules/@eslint/eslintrc/node_modules/minimatch"]
64
+ },
65
+ "brace-expansion": {
66
+ "name": "brace-expansion",
67
+ "severity": "high",
68
+ "via": [
69
+ {
70
+ "name": "brace-expansion",
71
+ "dependency": "brace-expansion",
72
+ "url": "https://github.com/advisories/GHSA-meta-chain",
73
+ "severity": "high",
74
+ "range": "<=5.0.7"
75
+ }
76
+ ],
77
+ "nodes": ["node_modules/@eslint/eslintrc/node_modules/brace-expansion"]
78
+ }
79
+ }
80
+ }
81
+ JSON
82
+ }
83
+
40
84
  write_lock() {
41
85
  cat > "$WORK/package-lock.json" <<'JSON'
42
86
  {
@@ -50,6 +94,21 @@ write_lock() {
50
94
  JSON
51
95
  }
52
96
 
97
+ write_meta_lock() {
98
+ cat > "$WORK/package-lock.json" <<'JSON'
99
+ {
100
+ "lockfileVersion": 3,
101
+ "packages": {
102
+ "": {"name": "fixture"},
103
+ "node_modules/eslint": {"version": "9.0.0"},
104
+ "node_modules/@eslint/eslintrc": {"version": "3.0.0"},
105
+ "node_modules/@eslint/eslintrc/node_modules/minimatch": {"version": "9.0.0"},
106
+ "node_modules/@eslint/eslintrc/node_modules/brace-expansion": {"version": "2.0.1"}
107
+ }
108
+ }
109
+ JSON
110
+ }
111
+
53
112
  write_exception() {
54
113
  cat > "$WORK/compliance/security/accepted-vulnerabilities.json" <<'JSON'
55
114
  {
@@ -66,11 +125,16 @@ write_exception() {
66
125
  "expiresAt": "2026-12-31",
67
126
  "approvedBy": "reviewer@example.test",
68
127
  "reason": "Fixture acceptance while the introducing dependency awaits an upstream repair.",
69
- "remediationIssue": "https://github.com/example/repo/issues/1"
128
+ "remediationIssue": "https://github.com/example/repo/issues/1",
129
+ "compensatingControls": "Monitor upstream releases and retain the unaffected root package."
70
130
  }
71
131
  ]
72
132
  }
73
133
  JSON
134
+ jq --arg approved "$APPROVED_AT" --arg expires "$EXPIRES_AT" \
135
+ '.exceptions[0].approvedAt = $approved | .exceptions[0].expiresAt = $expires' \
136
+ "$WORK/compliance/security/accepted-vulnerabilities.json" > "$WORK/changed.json"
137
+ mv "$WORK/changed.json" "$WORK/compliance/security/accepted-vulnerabilities.json"
74
138
  }
75
139
 
76
140
  run_helper() {
@@ -107,7 +171,7 @@ jq -e '.summary.accepted == 0 and .summary.unresolved == 0' "$WORK/dependency-ri
107
171
  write_audit
108
172
  write_exception
109
173
  expect_success
110
- jq -e '.summary.accepted == 1 and .summary.unresolved == 0 and .accepted[0].acceptance.remediationIssue == "https://github.com/example/repo/issues/1"' \
174
+ jq -e '.summary.accepted == 1 and .summary.unresolved == 0 and .accepted[0].acceptance.remediationIssue == "https://github.com/example/repo/issues/1" and (.accepted[0].acceptance.compensatingControls | length > 0)' \
111
175
  "$WORK/dependency-risk-evaluation.json" >/dev/null
112
176
 
113
177
  jq '.exceptions[0].advisoryId = "GHSA-wrong"' "$WORK/compliance/security/accepted-vulnerabilities.json" > "$WORK/changed.json"
@@ -140,7 +204,7 @@ expect_failure
140
204
 
141
205
  write_exception
142
206
  write_audit
143
- jq '.exceptions[0].expiresAt = "2026-01-01"' "$WORK/compliance/security/accepted-vulnerabilities.json" > "$WORK/changed.json"
207
+ jq --arg expired "$EXPIRED_AT" '.exceptions[0].expiresAt = $expired' "$WORK/compliance/security/accepted-vulnerabilities.json" > "$WORK/changed.json"
144
208
  mv "$WORK/changed.json" "$WORK/compliance/security/accepted-vulnerabilities.json"
145
209
  expect_failure
146
210
 
@@ -150,6 +214,18 @@ jq '.exceptions[0].expiresAt = "not-a-date"' "$WORK/compliance/security/accepted
150
214
  mv "$WORK/changed.json" "$WORK/compliance/security/accepted-vulnerabilities.json"
151
215
  expect_failure
152
216
 
217
+ write_exception
218
+ write_audit
219
+ jq '.exceptions[0].remediationIssue = "https://github.com/example/repo"' "$WORK/compliance/security/accepted-vulnerabilities.json" > "$WORK/changed.json"
220
+ mv "$WORK/changed.json" "$WORK/compliance/security/accepted-vulnerabilities.json"
221
+ expect_failure
222
+
223
+ write_exception
224
+ write_audit
225
+ jq '.exceptions[0].compensatingControls = []' "$WORK/compliance/security/accepted-vulnerabilities.json" > "$WORK/changed.json"
226
+ mv "$WORK/changed.json" "$WORK/compliance/security/accepted-vulnerabilities.json"
227
+ expect_failure
228
+
153
229
  write_exception
154
230
  write_audit
155
231
  jq 'del(.exceptions[0].approvedAt)' "$WORK/compliance/security/accepted-vulnerabilities.json" > "$WORK/changed.json"
@@ -173,4 +249,33 @@ write_exception
173
249
  printf '{not-json}\n' > "$WORK/compliance/security/accepted-vulnerabilities.json"
174
250
  expect_failure
175
251
 
252
+ write_meta_audit
253
+ write_meta_lock
254
+ rm -f "$WORK/compliance/security/accepted-vulnerabilities.json"
255
+ expect_failure
256
+ jq -e '
257
+ .summary.unresolved == 1 and
258
+ .unresolved[0].advisoryId == "GHSA-meta-chain" and
259
+ .unresolved[0].dependencyPath == "node_modules/@eslint/eslintrc/node_modules/brace-expansion" and
260
+ .unresolved[0].affectedFindings == ["@eslint/eslintrc", "brace-expansion", "eslint", "minimatch"]
261
+ ' "$WORK/dependency-risk-evaluation.json" >/dev/null
262
+
263
+ write_meta_audit
264
+ jq '.vulnerabilities.minimatch.via = ["missing-package"]' \
265
+ "$WORK/dependency-audit.json" > "$WORK/changed.json"
266
+ mv "$WORK/changed.json" "$WORK/dependency-audit.json"
267
+ expect_failure
268
+
269
+ write_meta_audit
270
+ jq '.vulnerabilities["brace-expansion"].via = ["eslint"]' \
271
+ "$WORK/dependency-audit.json" > "$WORK/changed.json"
272
+ mv "$WORK/changed.json" "$WORK/dependency-audit.json"
273
+ expect_failure
274
+
275
+ write_meta_audit
276
+ jq '.vulnerabilities["brace-expansion"].via = []' \
277
+ "$WORK/dependency-audit.json" > "$WORK/changed.json"
278
+ mv "$WORK/changed.json" "$WORK/dependency-audit.json"
279
+ expect_failure
280
+
176
281
  echo "evaluate-npm-audit: PASS"
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env bash
2
+ # Context-aware helpers for simple GitHub-flavoured Markdown tables.
3
+
4
+ markdown_table_cell() {
5
+ local file="$1"
6
+ local key_value="$2"
7
+ local target_header="$3"
8
+ shift 3
9
+ local required_headers=("$@")
10
+
11
+ [ -f "$file" ] || return 1
12
+ awk \
13
+ -v key_value="$key_value" \
14
+ -v target_header="$target_header" \
15
+ -v required_headers="$(IFS=,; printf '%s' "${required_headers[*]}")" '
16
+ function clean(value) {
17
+ gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
18
+ gsub(/\001/, "\\|", value)
19
+ return value
20
+ }
21
+ function split_row(line, cells, protected) {
22
+ protected=line
23
+ gsub(/\\\|/, "\001", protected)
24
+ return split(protected, cells, "|")
25
+ }
26
+ function header_matches(name, required, options, count, i) {
27
+ count=split(required, options, "/")
28
+ for (i=1; i<=count; i++) {
29
+ if (tolower(name) == tolower(options[i])) return 1
30
+ }
31
+ return 0
32
+ }
33
+ BEGIN {
34
+ required_count=split(required_headers, required, ",")
35
+ active=0
36
+ }
37
+ /^\|/ {
38
+ count=split_row($0, cells)
39
+ candidate_target=0
40
+ candidate_key=0
41
+ matched=0
42
+ for (i=2; i<count; i++) {
43
+ name=clean(cells[i])
44
+ if (tolower(name) == tolower(target_header)) candidate_target=i
45
+ for (r=1; r<=required_count; r++) {
46
+ if (header_matches(name, required[r])) matched++
47
+ }
48
+ if (header_matches(name, required[1])) candidate_key=i
49
+ }
50
+ if (candidate_target && candidate_key && matched >= required_count) {
51
+ active=1
52
+ target_index=candidate_target
53
+ key_index=candidate_key
54
+ next
55
+ }
56
+ if (active && key_index && target_index && clean(cells[key_index]) == key_value) {
57
+ print clean(cells[target_index])
58
+ found=1
59
+ exit 0
60
+ }
61
+ next
62
+ }
63
+ { active=0; key_index=0; target_index=0 }
64
+ END { if (!found) exit 1 }
65
+ ' "$file"
66
+ }
67
+
68
+ markdown_table_column_index_for_row() {
69
+ local file="$1"
70
+ local key_value="$2"
71
+ local target_header="$3"
72
+ shift 3
73
+ local required_headers=("$@")
74
+
75
+ [ -f "$file" ] || return 1
76
+ awk \
77
+ -v key_value="$key_value" \
78
+ -v target_header="$target_header" \
79
+ -v required_headers="$(IFS=,; printf '%s' "${required_headers[*]}")" '
80
+ function clean(value) {
81
+ gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
82
+ return value
83
+ }
84
+ function split_row(line, cells, protected) {
85
+ protected=line
86
+ gsub(/\\\|/, "\001", protected)
87
+ return split(protected, cells, "|")
88
+ }
89
+ function header_matches(name, required, options, count, i) {
90
+ count=split(required, options, "/")
91
+ for (i=1; i<=count; i++) {
92
+ if (tolower(name) == tolower(options[i])) return 1
93
+ }
94
+ return 0
95
+ }
96
+ BEGIN { required_count=split(required_headers, required, ","); active=0 }
97
+ /^\|/ {
98
+ count=split_row($0, cells)
99
+ candidate_target=0
100
+ candidate_key=0
101
+ matched=0
102
+ for (i=2; i<count; i++) {
103
+ name=clean(cells[i])
104
+ if (tolower(name) == tolower(target_header)) candidate_target=i
105
+ for (r=1; r<=required_count; r++) {
106
+ if (header_matches(name, required[r])) matched++
107
+ }
108
+ if (header_matches(name, required[1])) candidate_key=i
109
+ }
110
+ if (candidate_target && candidate_key && matched >= required_count) {
111
+ active=1
112
+ target_index=candidate_target
113
+ key_index=candidate_key
114
+ next
115
+ }
116
+ if (active && clean(cells[key_index]) == key_value) {
117
+ print target_index
118
+ found=1
119
+ exit 0
120
+ }
121
+ next
122
+ }
123
+ { active=0 }
124
+ END { if (!found) exit 1 }
125
+ ' "$file"
126
+ }
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+ # shellcheck source=markdown-table.sh
6
+ source "$SCRIPT_DIR/markdown-table.sh"
7
+
8
+ WORK="$(mktemp -d)"
9
+ trap 'rm -rf "$WORK"' EXIT
10
+
11
+ cat > "$WORK/RTM.md" <<'MARKDOWN'
12
+ | Status | Description |
13
+ | --- | --- |
14
+ | Draft | This is not the requirements table |
15
+
16
+ | REQ-ID | Issue | Risk | Evidence | Status |
17
+ | --- | --- | --- | --- | --- |
18
+ | REQ-100 | #100 | HIGH | report.md | TESTED - PENDING SIGN-OFF (note \| retained) |
19
+ MARKDOWN
20
+
21
+ [ "$(markdown_table_cell "$WORK/RTM.md" REQ-100 Status REQ-ID/ID Status)" = \
22
+ 'TESTED - PENDING SIGN-OFF (note \| retained)' ]
23
+ [ "$(markdown_table_cell "$WORK/RTM.md" REQ-100 Risk REQ-ID/ID Risk)" = "HIGH" ]
24
+ [ "$(markdown_table_column_index_for_row "$WORK/RTM.md" REQ-100 Status REQ-ID/ID Status)" = "5" ]
25
+
26
+ if markdown_table_cell "$WORK/RTM.md" REQ-404 Status REQ-ID/ID Status >/dev/null; then
27
+ echo "missing row unexpectedly matched" >&2
28
+ exit 1
29
+ fi
30
+
31
+ if markdown_table_cell "$WORK/RTM.md" Draft Status REQ-ID/ID Status >/dev/null; then
32
+ echo "legend row unexpectedly matched" >&2
33
+ exit 1
34
+ fi
35
+
36
+ echo "markdown table tests passed"
@@ -12,6 +12,10 @@
12
12
 
13
13
  set -euo pipefail
14
14
 
15
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
16
+ # shellcheck source=markdown-table.sh
17
+ source "$SCRIPT_DIR/markdown-table.sh"
18
+
15
19
  BASE_BRANCH="${1:-origin/main}"
16
20
  EXIT_CODE=0
17
21
 
@@ -53,6 +57,7 @@ find_first_match() {
53
57
  local match=""
54
58
  match=$(compgen -G "$pattern" | head -1 || true)
55
59
  [ -n "$match" ] && printf '%s' "$match"
60
+ return 0
56
61
  }
57
62
 
58
63
  require_bundled_fields() {
@@ -81,7 +86,9 @@ for REQ in $REQUIREMENTS; do
81
86
  # The `| $REQ ` pattern (leading pipe + trailing space) matches the
82
87
  # markdown table row used in RTM, so a substring elsewhere doesn't
83
88
  # accidentally satisfy it.
84
- if ! grep -q "| $REQ " compliance/RTM.md 2>/dev/null; then
89
+ RTM_STATUS="$(markdown_table_cell \
90
+ compliance/RTM.md "$REQ" Status REQ-ID/ID Status 2>/dev/null || true)"
91
+ if [ -z "$RTM_STATUS" ]; then
85
92
  echo " INFO: $REQ is referenced in commits but has no RTM row — skipping (forward-reference, not a tracked requirement for this release)"
86
93
  continue
87
94
  fi
@@ -187,12 +194,12 @@ for REQ in $REQUIREMENTS; do
187
194
  # by a successor (e.g. REQ-030 superseded by REQ-031). Without this
188
195
  # branch every PR with a commit referencing a SUPERSEDED REQ blocks
189
196
  # CI (#232).
190
- if grep -q "$REQ" compliance/RTM.md 2>/dev/null; then
191
- if grep "$REQ" compliance/RTM.md | grep -q "TESTED - PENDING SIGN-OFF"; then
197
+ if [ -n "$RTM_STATUS" ]; then
198
+ if printf '%s' "$RTM_STATUS" | grep -q "TESTED - PENDING SIGN-OFF"; then
192
199
  echo " OK: RTM status is TESTED - PENDING SIGN-OFF"
193
- elif grep "$REQ" compliance/RTM.md | grep -q "APPROVED"; then
200
+ elif printf '%s' "$RTM_STATUS" | grep -q "APPROVED"; then
194
201
  echo " OK: RTM status is APPROVED"
195
- elif grep "$REQ" compliance/RTM.md | grep -q "SUPERSEDED"; then
202
+ elif printf '%s' "$RTM_STATUS" | grep -q "SUPERSEDED"; then
196
203
  echo " OK: RTM status is SUPERSEDED"
197
204
  else
198
205
  echo " WARNING: RTM entry exists but status is not TESTED - PENDING SIGN-OFF"
@@ -371,7 +378,9 @@ for REQ in $REQUIREMENTS; do
371
378
  fi
372
379
 
373
380
  # Check implementation-plan.md for MEDIUM/HIGH risk (check RTM for risk level)
374
- if grep "$REQ" compliance/RTM.md 2>/dev/null | grep -qiE 'MEDIUM|HIGH'; then
381
+ RTM_RISK="$(markdown_table_cell \
382
+ compliance/RTM.md "$REQ" Risk REQ-ID/ID Risk 2>/dev/null || true)"
383
+ if printf '%s' "$RTM_RISK" | grep -qiE 'MEDIUM|HIGH'; then
375
384
  if [ ! -f "compliance/evidence/$REQ/implementation-plan.md" ]; then
376
385
  echo " ERROR: Implementation plan missing for MEDIUM/HIGH risk: compliance/evidence/$REQ/implementation-plan.md"
377
386
  EXIT_CODE=1
@@ -341,7 +341,7 @@ Reached from Phase 0 for non-tracked change-types. The skill drives this end-to-
341
341
  - **Post-merge-only CI (older generated workflows — `push: branches: [<integration>]` with no `pull_request:` trigger)** — say so explicitly in the LAST/NEXT sticky: _"no PR-time checks will fire; review + merge is the gate; CI runs post-merge on `$INTEGRATION_BRANCH`."_ Don't poll the PR for checks that won't arrive. The post-merge run (CI Pipeline + Compliance Evidence Upload on the integration branch) is the actual gate; address it via fix-forward if it fails.
342
342
 
343
343
  Either way, never bypass a gate (no `--no-verify`, no `--admin` merge of a red required check); the only difference is **where** you wait for the gate to fire — before merge vs. after merge.
344
- 8. **Guide review -> merge.** A human still reviews the PR. Merge only after all required checks are terminal green on the current PR head SHA; queued, running, stale, cancelled, unexpectedly skipped, or failed checks are not green. There is no portal release approval, UAT four-eyes, Production gate, or standalone close-out on the normal lightweight path.
344
+ 8. **Guide review -> merge.** A human still reviews the PR. Merge only after all required checks are terminal green on the current PR head SHA; queued, running, stale, cancelled, unexpectedly skipped, or failed checks are not green. If GitHub leaves a stale projection, use the repository's **Recover Required Checks** workflow: dry-run first, then apply only to rerun a resolvable completed Actions run on the same SHA. When GitHub never created the required suite, push an auditable empty commit to the PR branch and verify the new SHA; never fabricate status or bypass protection. There is no portal release approval, UAT four-eyes, Production gate, or standalone close-out on the normal lightweight path.
345
345
  9. **Record the correct release meaning.** A housekeeping push produces at most a bare-date release (`vYYYY.MM.DD`) that is integration/history, not an active approval by default. It waits on `$INTEGRATION_BRANCH` until a later tracked release explicitly absorbs it through the required bundled-changes markdown/JSON manifest and portal lineage. Never infer that an unrelated hotfix or mandatory back-merge belongs to the only pending REQ. A submitted REQ bundle is frozen approval scope; changing it returns that REQ to evidence compilation and UAT resubmission. A doc-only push attaches documents to the existing `REQ-XXX` release.
346
346
  10. **Standalone exception.** Only when housekeeping cannot reasonably wait for the next tracked release may the skill prepare a standalone promotion. It must state `Standalone housekeeping promotion` and why in the release PR, add `compliance/standalone-housekeeping/STANDALONE-HOUSEKEEPING-vYYYY.MM.DD.json` with `schemaVersion: 1`, the exact derived bare-date version, `releaseMode: standalone_housekeeping`, and a specific reason. It must obtain terminal-green CI and PR review. The installed release-scope check validates the declaration before merge; post-deploy uploads it as release-ticket evidence and changes the portal row to standalone housekeeping. Use portal approval only if project policy explicitly opts in. If the installed workflow cannot implement that policy, halt and keep the change on the normal integration path.
347
347