|
| 1 | +#!/usr/bin/env bash |
| 2 | +set -euo pipefail |
| 3 | + |
| 4 | +IDEA_NAME="${1:-}" |
| 5 | +EXPECTED_FINGERPRINT="${2:-}" |
| 6 | +NOW_UTC="${3:-}" |
| 7 | + |
| 8 | +ROOT_DIR="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" |
| 9 | +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" |
| 10 | +IDEA_DIR="${ROOT_DIR}/project-ideas/${IDEA_NAME}" |
| 11 | +REG_DIR="${IDEA_DIR}/regulatory" |
| 12 | + |
| 13 | +MANIFEST_FILE="${REG_DIR}/regulatory-manifest.md" |
| 14 | +SURFACE_FILE="${REG_DIR}/02-regulatory-surface.md" |
| 15 | +EVAL_FILE="${REG_DIR}/02b-regulatory-evaluation.md" |
| 16 | +SOURCES_FILE="${REG_DIR}/regulatory-sources.md" |
| 17 | +IMPLICATIONS_FILE="${REG_DIR}/regulatory-capability-implications.md" |
| 18 | +EXCEPTION_FILE="${REG_DIR}/regulatory-exception.md" |
| 19 | +DECISION_LOG_FILE="${IDEA_DIR}/decision-log.md" |
| 20 | + |
| 21 | +usage() { |
| 22 | + echo "Usage (canonical): ./.codex/scripts/product-idea-regulatory-intake-validate.sh <idea-name> [expected-input-fingerprint] [current-utc]" |
| 23 | + echo "Usage (repo-local fallback): ./codex/scripts/product-idea-regulatory-intake-validate.sh <idea-name> [expected-input-fingerprint] [current-utc]" |
| 24 | + echo "Usage (home fallback): ${HOME}/.codex/scripts/product-idea-regulatory-intake-validate.sh <idea-name> [expected-input-fingerprint] [current-utc]" |
| 25 | +} |
| 26 | + |
| 27 | +now_utc_default() { |
| 28 | + date -u +"%Y-%m-%dT%H:%M:%SZ" |
| 29 | +} |
| 30 | + |
| 31 | +is_rfc3339_utc() { |
| 32 | + [[ "${1}" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]] |
| 33 | +} |
| 34 | + |
| 35 | +extract_field() { |
| 36 | + local file="$1" |
| 37 | + local key="$2" |
| 38 | + awk -v key="${key}" ' |
| 39 | + BEGIN { key_l = tolower(key) } |
| 40 | + { |
| 41 | + line = $0 |
| 42 | + gsub(/^[[:space:]]*[-*][[:space:]]*/, "", line) |
| 43 | + sub(/^[[:space:]]+/, "", line) |
| 44 | + line_l = tolower(line) |
| 45 | + if (line_l ~ ("^" key_l ":[[:space:]]*")) { |
| 46 | + sub(/^[^:]+:[[:space:]]*/, "", line) |
| 47 | + print line |
| 48 | + exit |
| 49 | + } |
| 50 | + } |
| 51 | + ' "${file}" |
| 52 | +} |
| 53 | + |
| 54 | +get_manifest_field() { |
| 55 | + local value |
| 56 | + for key in "$@"; do |
| 57 | + value="$(extract_field "${MANIFEST_FILE}" "${key}")" |
| 58 | + if [[ -n "${value}" ]]; then |
| 59 | + printf '%s' "${value}" |
| 60 | + return |
| 61 | + fi |
| 62 | + done |
| 63 | +} |
| 64 | + |
| 65 | +issues=() |
| 66 | + |
| 67 | +if [[ -z "${IDEA_NAME}" ]]; then |
| 68 | + usage |
| 69 | + exit 2 |
| 70 | +fi |
| 71 | + |
| 72 | +if [[ ! "${IDEA_NAME}" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then |
| 73 | + echo "ERROR: idea-name must be kebab-case using lowercase letters, digits, and hyphens only." >&2 |
| 74 | + exit 2 |
| 75 | +fi |
| 76 | + |
| 77 | +if [[ -z "${NOW_UTC}" ]]; then |
| 78 | + NOW_UTC="$(now_utc_default)" |
| 79 | +fi |
| 80 | + |
| 81 | +if ! is_rfc3339_utc "${NOW_UTC}"; then |
| 82 | + echo "ERROR: current-utc must be RFC 3339 UTC format (YYYY-MM-DDTHH:MM:SSZ)." >&2 |
| 83 | + exit 2 |
| 84 | +fi |
| 85 | + |
| 86 | +if [[ ! -d "${REG_DIR}" ]]; then |
| 87 | + issues+=("Missing required directory: ${REG_DIR}") |
| 88 | +fi |
| 89 | + |
| 90 | +if [[ ! -f "${MANIFEST_FILE}" ]]; then |
| 91 | + issues+=("Missing required file: ${MANIFEST_FILE}") |
| 92 | +fi |
| 93 | + |
| 94 | +if [[ ! -f "${SURFACE_FILE}" ]]; then |
| 95 | + issues+=("Missing required file: ${SURFACE_FILE}") |
| 96 | +fi |
| 97 | + |
| 98 | +if [[ ! -f "${SOURCES_FILE}" ]]; then |
| 99 | + issues+=("Missing required file: ${SOURCES_FILE}") |
| 100 | +fi |
| 101 | + |
| 102 | +if [[ ! -f "${IMPLICATIONS_FILE}" ]]; then |
| 103 | + issues+=("Missing required file: ${IMPLICATIONS_FILE}") |
| 104 | +fi |
| 105 | + |
| 106 | +status="" |
| 107 | +generated_at="" |
| 108 | +expires_at="" |
| 109 | +input_fingerprint="" |
| 110 | + |
| 111 | +if [[ -f "${MANIFEST_FILE}" ]]; then |
| 112 | + status="$(get_manifest_field "status")" |
| 113 | + generated_at="$(get_manifest_field "generated_at" "generated at")" |
| 114 | + expires_at="$(get_manifest_field "expires_at" "expires at")" |
| 115 | + input_fingerprint="$(get_manifest_field "input_fingerprint" "input fingerprint")" |
| 116 | + |
| 117 | + case "${status}" in |
| 118 | + SURFACE_FOUND|NO_SURFACE|EXCEPTION_APPROVED|INCONCLUSIVE) ;; |
| 119 | + *) |
| 120 | + issues+=("Invalid or missing manifest status in ${MANIFEST_FILE}. Expected SURFACE_FOUND|NO_SURFACE|EXCEPTION_APPROVED|INCONCLUSIVE.") |
| 121 | + ;; |
| 122 | + esac |
| 123 | + |
| 124 | + if [[ "${status}" == "INCONCLUSIVE" ]]; then |
| 125 | + issues+=("Manifest status is INCONCLUSIVE; regulatory intake cannot progress without EXCEPTION_APPROVED.") |
| 126 | + fi |
| 127 | + |
| 128 | + if [[ "${status}" == "SURFACE_FOUND" && ! -f "${EVAL_FILE}" ]]; then |
| 129 | + issues+=("Missing required file for SURFACE_FOUND status: ${EVAL_FILE}") |
| 130 | + fi |
| 131 | + |
| 132 | + if [[ -z "${generated_at}" ]]; then |
| 133 | + issues+=("Missing manifest field: generated_at") |
| 134 | + elif ! is_rfc3339_utc "${generated_at}"; then |
| 135 | + issues+=("Invalid generated_at format in ${MANIFEST_FILE}; expected RFC 3339 UTC.") |
| 136 | + fi |
| 137 | + |
| 138 | + if [[ -z "${expires_at}" ]]; then |
| 139 | + issues+=("Missing manifest field: expires_at") |
| 140 | + elif ! is_rfc3339_utc "${expires_at}"; then |
| 141 | + issues+=("Invalid expires_at format in ${MANIFEST_FILE}; expected RFC 3339 UTC.") |
| 142 | + elif [[ "${NOW_UTC}" > "${expires_at}" || "${NOW_UTC}" == "${expires_at}" ]]; then |
| 143 | + issues+=("Manifest is stale: current time (${NOW_UTC}) is at/after expires_at (${expires_at}).") |
| 144 | + fi |
| 145 | + |
| 146 | + if [[ -z "${input_fingerprint}" ]]; then |
| 147 | + issues+=("Missing manifest field: input_fingerprint") |
| 148 | + fi |
| 149 | +fi |
| 150 | + |
| 151 | +if [[ -z "${EXPECTED_FINGERPRINT}" ]]; then |
| 152 | + if [[ -x "${SCRIPT_DIR}/product-idea-scope-fingerprint.sh" ]]; then |
| 153 | + if ! EXPECTED_FINGERPRINT="$("${SCRIPT_DIR}/product-idea-scope-fingerprint.sh" "${IDEA_NAME}" 2>/dev/null)"; then |
| 154 | + issues+=("Unable to derive expected input fingerprint from project baseline/surface files.") |
| 155 | + fi |
| 156 | + else |
| 157 | + issues+=("Missing fingerprint helper script: ${SCRIPT_DIR}/product-idea-scope-fingerprint.sh") |
| 158 | + fi |
| 159 | +fi |
| 160 | + |
| 161 | +if [[ -n "${EXPECTED_FINGERPRINT}" && -n "${input_fingerprint}" && "${input_fingerprint}" != "${EXPECTED_FINGERPRINT}" ]]; then |
| 162 | + issues+=("Manifest fingerprint mismatch: manifest=${input_fingerprint}, expected=${EXPECTED_FINGERPRINT}.") |
| 163 | +fi |
| 164 | + |
| 165 | +if [[ "${status}" == "EXCEPTION_APPROVED" ]]; then |
| 166 | + if [[ ! -f "${EXCEPTION_FILE}" ]]; then |
| 167 | + issues+=("Missing required exception file for EXCEPTION_APPROVED status: ${EXCEPTION_FILE}") |
| 168 | + else |
| 169 | + for field in "rationale" "scope" "owner" "expiry date" "accepted risks" "mitigation plan"; do |
| 170 | + if [[ -z "$(extract_field "${EXCEPTION_FILE}" "${field}")" ]]; then |
| 171 | + issues+=("Missing required exception field '${field}' in ${EXCEPTION_FILE}.") |
| 172 | + fi |
| 173 | + done |
| 174 | + fi |
| 175 | + |
| 176 | + if [[ ! -f "${DECISION_LOG_FILE}" ]]; then |
| 177 | + issues+=("Missing decision log required for exception risk flag: ${DECISION_LOG_FILE}") |
| 178 | + elif ! grep -Eqi 'risk flag|regulatory-exception|exception approved' "${DECISION_LOG_FILE}"; then |
| 179 | + issues+=("Decision log must record explicit risk flag for exception usage: ${DECISION_LOG_FILE}") |
| 180 | + fi |
| 181 | +fi |
| 182 | + |
| 183 | +if [[ "${#issues[@]}" -gt 0 ]]; then |
| 184 | + echo "BLOCKED" |
| 185 | + for issue in "${issues[@]}"; do |
| 186 | + echo "- ${issue}" |
| 187 | + done |
| 188 | + exit 1 |
| 189 | +fi |
| 190 | + |
| 191 | +echo "REGULATORY INTAKE PASSED" |
| 192 | +echo "- status: ${status}" |
| 193 | +echo "- fingerprint: ${input_fingerprint}" |
| 194 | +echo "- expires_at: ${expires_at}" |
| 195 | +exit 0 |
0 commit comments