@techgoblin/gobstack 0.0.0-stage → 0.4.4-beta.2
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/CHANGELOG.md +351 -0
- package/LICENSE +21 -0
- package/README.md +217 -2
- package/VERSION +1 -0
- package/adapters/_template/adapter.tsv +16 -0
- package/adapters/_template/detect.sh +10 -0
- package/adapters/_template/emit.sh +5 -0
- package/adapters/_template/verify.sh +4 -0
- package/adapters/claude/adapter.tsv +8 -0
- package/adapters/claude/detect.sh +8 -0
- package/adapters/claude/verify.sh +47 -0
- package/adapters/codex/adapter.tsv +12 -0
- package/adapters/codex/detect.sh +9 -0
- package/adapters/codex/verify.sh +45 -0
- package/adapters/copilot/adapter.tsv +10 -0
- package/adapters/copilot/detect.sh +8 -0
- package/adapters/copilot/verify.sh +45 -0
- package/adapters/cursor/adapter.tsv +11 -0
- package/adapters/cursor/detect.sh +10 -0
- package/adapters/cursor/verify.sh +45 -0
- package/adapters/gemini/adapter.tsv +15 -0
- package/adapters/gemini/detect.sh +11 -0
- package/adapters/gemini/verify.sh +49 -0
- package/adapters/hermes/adapter.tsv +9 -0
- package/adapters/hermes/detect.sh +8 -0
- package/adapters/hermes/verify.sh +27 -0
- package/adapters/opencode/adapter.tsv +14 -0
- package/adapters/opencode/detect.sh +9 -0
- package/adapters/opencode/verify.sh +45 -0
- package/automations/README.md +53 -0
- package/automations/bugreporter-intake.sh +145 -0
- package/automations/drift-audit.sh +139 -0
- package/automations/report.schema.tsv +10 -0
- package/bans/README.md +82 -0
- package/bans/grep-ban.sh +84 -0
- package/bans/layer-check.sh +57 -0
- package/bin/goblin +119 -0
- package/bin/goblin-audit +145 -0
- package/bin/goblin-bans +178 -0
- package/bin/goblin-doctor +233 -0
- package/bin/goblin-emit +484 -0
- package/bin/goblin-init +519 -0
- package/bin/goblin-install +720 -0
- package/bin/goblin-lib.sh +289 -0
- package/bin/goblin-model +105 -0
- package/bin/goblin-upgrade +572 -0
- package/bin/goblin-verify +2798 -0
- package/bin/goblin.js +103 -0
- package/docs/ADOPTION.md +168 -0
- package/docs/CI.md +187 -0
- package/docs/CONTRACTS.md +197 -0
- package/docs/DESIGN.md +92 -0
- package/docs/ENFORCEMENT.md +225 -0
- package/docs/FLOWS.md +164 -0
- package/docs/GUARDRAILS.md +126 -0
- package/docs/GUIDE.md +610 -0
- package/docs/INTEGRATION.md +92 -0
- package/docs/LIMITS.md +591 -0
- package/docs/LOOP.md +165 -0
- package/docs/RE-PLAYBOOK.md +183 -0
- package/docs/RISKS.md +70 -0
- package/docs/ROLES.md +105 -0
- package/manifest/bans.tsv +9 -0
- package/manifest/classes.tsv +61 -0
- package/manifest/enforcement.tsv +88 -0
- package/manifest/glossary.tsv +25 -0
- package/manifest/playbooks.tsv +16 -0
- package/package.json +37 -4
- package/presets/A-shipped-software.yaml +48 -0
- package/presets/B-service-config.yaml +40 -0
- package/presets/C-game.yaml +38 -0
- package/presets/D-knowledge.yaml +41 -0
- package/presets/E-fleet-config.yaml +42 -0
- package/presets/F-electron.yaml +67 -0
- package/roles.yaml +54 -0
- package/skills/goblin-bootstrap/SKILL.md +51 -0
- package/skills/goblin-bugfix/SKILL.md +26 -0
- package/skills/goblin-bugreporter/SKILL.md +52 -0
- package/skills/goblin-drift-audit/SKILL.md +43 -0
- package/skills/goblin-eval/SKILL.md +68 -0
- package/skills/goblin-feature/SKILL.md +26 -0
- package/skills/goblin-feature-map/SKILL.md +140 -0
- package/skills/goblin-handoff/SKILL.md +28 -0
- package/skills/goblin-investigation/SKILL.md +26 -0
- package/skills/goblin-judge/SKILL.md +74 -0
- package/skills/goblin-loop/SKILL.md +88 -0
- package/skills/goblin-mode/SKILL.md +70 -0
- package/skills/goblin-overnight/SKILL.md +42 -0
- package/skills/goblin-pr-gate/SKILL.md +42 -0
- package/skills/goblin-re-mobile/SKILL.md +51 -0
- package/skills/goblin-refactor/SKILL.md +23 -0
- package/skills/goblin-sweep/SKILL.md +23 -0
- package/skills/goblin-tdd-repro/SKILL.md +27 -0
- package/skills/goblin-verify-author/SKILL.md +50 -0
- package/skills/practice/SKILL.md +37 -0
- package/templates/AGENTS.md.tmpl +23 -0
- package/templates/HANDOFF.md.tmpl +43 -0
- package/templates/SPEC.md.tmpl +34 -0
- package/templates/audit-waiver.tsv.tmpl +10 -0
- package/templates/boundary-waivers.tmpl +8 -0
- package/templates/checks/assert.mjs.tmpl +60 -0
- package/templates/checks/gate.sh.tmpl +29 -0
- package/templates/ci/goblin-gate.yml.tmpl +46 -0
- package/templates/goblin.yaml.tmpl +138 -0
- package/templates/install-hooks.allowlist.tmpl +9 -0
- package/templates/loop/decisions.tsv.tmpl +1 -0
- package/templates/loop/predicate.tmpl +16 -0
- package/templates/report.yaml.tmpl +16 -0
|
@@ -0,0 +1,2798 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# goblin-verify — execute the target-scope rows of manifest/enforcement.tsv.
|
|
3
|
+
#
|
|
4
|
+
# Usage:
|
|
5
|
+
# goblin-verify [--only <id[,id...]>] [--json] [--list] [--source <goblin-stack path>]
|
|
6
|
+
#
|
|
7
|
+
# Exit codes:
|
|
8
|
+
# 0 every executed check passed (advisories and skips do not fail the run)
|
|
9
|
+
# 1 at least one check FAILED
|
|
10
|
+
# 2 verify could not run (not installed, missing dependency, unparseable config)
|
|
11
|
+
# 3 the manifest itself is broken (a row with no check and no 'advisory', or a duplicate id)
|
|
12
|
+
#
|
|
13
|
+
# The check column of each row holds either
|
|
14
|
+
# * a shell command, run from the target repo root, or
|
|
15
|
+
# * the literal 'advisory' (counted, never executed), or
|
|
16
|
+
# * 'goblin-verify --only <ID>', the marker for a check that needs logic beyond one
|
|
17
|
+
# shell line. Those are implemented as builtins below; the marker keeps the row
|
|
18
|
+
# self-describing and keeps the matrix the single source of truth.
|
|
19
|
+
|
|
20
|
+
set -uo pipefail
|
|
21
|
+
|
|
22
|
+
GOBLIN_VERIFY_VERSION="0.4.4"
|
|
23
|
+
SELF_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
24
|
+
# shellcheck source=goblin-lib.sh
|
|
25
|
+
. "$SELF_DIR/goblin-lib.sh"
|
|
26
|
+
|
|
27
|
+
ONLY=""
|
|
28
|
+
JSON=0
|
|
29
|
+
LIST=0
|
|
30
|
+
SOURCE=""
|
|
31
|
+
|
|
32
|
+
usage() {
|
|
33
|
+
cat <<'USAGE'
|
|
34
|
+
goblin-verify — prove a goblin-stack install is still what it says it is.
|
|
35
|
+
|
|
36
|
+
--only <id[,id...]> run only these manifest rows
|
|
37
|
+
--list list the manifest rows and exit
|
|
38
|
+
--json machine-readable output
|
|
39
|
+
--source <path> read the manifest from a goblin-stack checkout instead of .goblin/
|
|
40
|
+
|
|
41
|
+
Exit codes: 0 pass | 1 a check failed | 2 could not run | 3 the manifest is broken.
|
|
42
|
+
USAGE
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
while [ $# -gt 0 ]; do
|
|
46
|
+
case "$1" in
|
|
47
|
+
--only) ONLY="${2:-}"; shift 2 ;;
|
|
48
|
+
--json) JSON=1; shift ;;
|
|
49
|
+
--list) LIST=1; shift ;;
|
|
50
|
+
--source) SOURCE="${2:-}"; shift 2 ;;
|
|
51
|
+
-h|--help) usage; exit 0 ;;
|
|
52
|
+
*) g_err "unknown option: $1"; usage >&2; exit 2 ;;
|
|
53
|
+
esac
|
|
54
|
+
done
|
|
55
|
+
|
|
56
|
+
# ---------------------------------------------------------------- environment --
|
|
57
|
+
for dep in git awk sed grep python3; do
|
|
58
|
+
command -v "$dep" >/dev/null 2>&1 || { g_err "missing dependency: $dep"; exit 2; }
|
|
59
|
+
done
|
|
60
|
+
|
|
61
|
+
# Root resolution. `git rev-parse --show-toplevel` alone verifies the OUTER repo when the
|
|
62
|
+
# target is nested inside another git repo (a monorepo subdirectory, anything under
|
|
63
|
+
# ~/.hermes) — the installed config is the target's own marker, so it wins (D20).
|
|
64
|
+
find_installed_root() {
|
|
65
|
+
local d="$PWD"
|
|
66
|
+
while :; do
|
|
67
|
+
if [ -f "$d/.goblin/goblin.yaml" ]; then printf '%s\n' "$d"; return 0; fi
|
|
68
|
+
[ "$d" = "/" ] && return 1
|
|
69
|
+
d=$(dirname "$d")
|
|
70
|
+
done
|
|
71
|
+
}
|
|
72
|
+
ROOT=$(find_installed_root) || ROOT=""
|
|
73
|
+
if [ -z "$ROOT" ]; then
|
|
74
|
+
ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
|
|
75
|
+
[ -n "$ROOT" ] || ROOT="$PWD"
|
|
76
|
+
fi
|
|
77
|
+
|
|
78
|
+
# ---- engine resolution (W1, §2.3): one chain, stated once --------------------
|
|
79
|
+
# Every engine-path lookup (enforcement.tsv, classes.tsv, bans.tsv, bans/*.sh, the ban
|
|
80
|
+
# engine) resolves through this chain, first hit wins:
|
|
81
|
+
# 1. --source <path> (existing flag; highest, for tests and pinned CI)
|
|
82
|
+
# 2. $ROOT/.goblin/... (vendored per-repo engine — today's default)
|
|
83
|
+
# 3. engine_dir: from $ROOT/.goblin/goblin.yaml
|
|
84
|
+
# 4. GOBLIN_ENGINE_DIR env, then ~/.goblin/engine
|
|
85
|
+
# 5. nothing found -> exit 2, naming every path tried (the pre-W1 message when the
|
|
86
|
+
# vendored chain is the only one tried; the §2.4 message when engine_dir: fails).
|
|
87
|
+
# Ordering is deliberate: a vendored engine still wins over the global one, because an
|
|
88
|
+
# explicit per-repo choice must not be silently overridden by a machine-level upgrade.
|
|
89
|
+
# An explicit engine_dir: that cannot be used is exit 2 with NO fallback (§1.2): falling
|
|
90
|
+
# back to a vendored engine would judge the repo with a different rule table than the one
|
|
91
|
+
# it declared. Absent key => the chain degrades to exactly today's behaviour (§1.3).
|
|
92
|
+
|
|
93
|
+
CONFIG="$ROOT/.goblin/goblin.yaml"
|
|
94
|
+
|
|
95
|
+
# engine_yaml_raw — the raw top-level engine_dir: value (the line-oriented parser reads
|
|
96
|
+
# the line, so a non-string scalar is visible here as something that is not a path).
|
|
97
|
+
engine_yaml_raw() {
|
|
98
|
+
sed -n "s/^engine_dir:[[:space:]]*//p" "$CONFIG" | head -n 1
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
# resolve_engine_dir — validate the declared key (§1.2, fail closed at use time, stages
|
|
102
|
+
# 1-4) and print the absolute engine directory. Stages 3/4 (existence, payload) do NOT
|
|
103
|
+
# exit here: the failure is reported through ENGINE_FAIL so the refusal can name BOTH the
|
|
104
|
+
# declared value and the chain tried (§2.4's message), then the run stops with exit 2 —
|
|
105
|
+
# no fallback, no SKIP.
|
|
106
|
+
ENGINE_DECLARED=""
|
|
107
|
+
ENGINE_FAIL=""
|
|
108
|
+
ENGINE_DIR_DECLARED="" # the declared value as written (for the §2.4 refusal message)
|
|
109
|
+
resolve_engine_dir() {
|
|
110
|
+
ENGINE_DECLARED=""
|
|
111
|
+
ENGINE_FAIL=""
|
|
112
|
+
ENGINE_DIR_DECLARED=""
|
|
113
|
+
[ -f "$CONFIG" ] || return 0
|
|
114
|
+
grep -q '^engine_dir:' "$CONFIG" || return 0
|
|
115
|
+
local dir
|
|
116
|
+
dir=$(engine_yaml_raw)
|
|
117
|
+
if [ -z "$dir" ]; then
|
|
118
|
+
ENGINE_FAIL="engine_dir: must be a string path, got empty"
|
|
119
|
+
return 1
|
|
120
|
+
fi
|
|
121
|
+
dir=$(g_unquote "$dir")
|
|
122
|
+
ENGINE_DIR_DECLARED="$dir"
|
|
123
|
+
# Stage 1: a non-string value (number, list, mapping, bare null/bool) is not a path.
|
|
124
|
+
# Anything that is not a usable path shape fails closed here and `got <value>` names
|
|
125
|
+
# what was declared; valid strings fall through to the shape check below.
|
|
126
|
+
ENGINE_FAIL="engine_dir: must be a string path, got $dir"
|
|
127
|
+
case "$dir" in
|
|
128
|
+
'~'|'null'|'true'|'false'|'['*|'{'*|''|*[!0-9A-Za-z_./~-]*|[0-9]*)
|
|
129
|
+
return 1 ;;
|
|
130
|
+
esac
|
|
131
|
+
ENGINE_FAIL=""
|
|
132
|
+
# Stage 2: the shape — absolute, or ~/ (expanded once, at read time, via $HOME).
|
|
133
|
+
case "$dir" in
|
|
134
|
+
/*) ;;
|
|
135
|
+
"~/"*) dir="$HOME/${dir#\~/}" ;;
|
|
136
|
+
*)
|
|
137
|
+
ENGINE_FAIL="engine_dir: must be absolute or ~/-prefixed: $dir"
|
|
138
|
+
return 1 ;;
|
|
139
|
+
esac
|
|
140
|
+
# Stage 3: existence.
|
|
141
|
+
if [ ! -d "$dir" ]; then
|
|
142
|
+
ENGINE_FAIL="engine_dir: $dir does not exist (declared in .goblin/goblin.yaml)"
|
|
143
|
+
return 1
|
|
144
|
+
fi
|
|
145
|
+
# Stage 4: the payload — a directory without the rule table is not an engine.
|
|
146
|
+
if [ ! -f "$dir/manifest/enforcement.tsv" ]; then
|
|
147
|
+
ENGINE_FAIL="engine_dir: no manifest/enforcement.tsv under $dir - this is not an engine directory"
|
|
148
|
+
return 1
|
|
149
|
+
fi
|
|
150
|
+
ENGINE_DECLARED="$dir"
|
|
151
|
+
return 0
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
resolve_engine_dir
|
|
155
|
+
ENGINE_CHAIN_VENDORED="\$ROOT/.goblin/manifest/enforcement.tsv"
|
|
156
|
+
GOBLIN_ENGINE="" # the resolved engine root, when NOT vendored/--source
|
|
157
|
+
if [ -n "$SOURCE" ]; then
|
|
158
|
+
MANIFEST="$SOURCE/manifest/enforcement.tsv"
|
|
159
|
+
CLASSES="$SOURCE/manifest/classes.tsv"
|
|
160
|
+
elif [ -f "$ROOT/.goblin/manifest/enforcement.tsv" ]; then
|
|
161
|
+
MANIFEST="$ROOT/.goblin/manifest/enforcement.tsv"
|
|
162
|
+
CLASSES="$ROOT/.goblin/manifest/classes.tsv"
|
|
163
|
+
elif [ -n "$ENGINE_DECLARED" ]; then
|
|
164
|
+
GOBLIN_ENGINE="$ENGINE_DECLARED"
|
|
165
|
+
MANIFEST="$GOBLIN_ENGINE/manifest/enforcement.tsv"
|
|
166
|
+
CLASSES="$GOBLIN_ENGINE/manifest/classes.tsv"
|
|
167
|
+
elif [ -n "$ENGINE_FAIL" ]; then
|
|
168
|
+
# §2.4: an explicit engine_dir: that does not resolve is exit 2, and nothing falls
|
|
169
|
+
# back. The refusal names the declared value and every path the chain tried.
|
|
170
|
+
g_err "$ENGINE_FAIL"
|
|
171
|
+
g_err "no manifest at any of: \$SOURCE (${SOURCE:-unset}), $ROOT/.goblin/manifest/enforcement.tsv (absent),"
|
|
172
|
+
g_err " $ENGINE_DIR_DECLARED/manifest/enforcement.tsv (unusable - see above)"
|
|
173
|
+
exit 2
|
|
174
|
+
else
|
|
175
|
+
# No vendored engine and no declaration: the CLI default (§3.3), env first, then
|
|
176
|
+
# ~/.goblin/engine. Both are consulted AFTER --source, vendored and the declaration:
|
|
177
|
+
# the most specific declaration wins; the machine default is the fallback, never an
|
|
178
|
+
# override (A9).
|
|
179
|
+
_cli_default=""
|
|
180
|
+
if [ -n "${GOBLIN_ENGINE_DIR:-}" ]; then
|
|
181
|
+
_cli_default="$GOBLIN_ENGINE_DIR"
|
|
182
|
+
elif [ -d "$HOME/.goblin/engine" ]; then
|
|
183
|
+
_cli_default="$HOME/.goblin/engine"
|
|
184
|
+
fi
|
|
185
|
+
if [ -n "$_cli_default" ] && [ -f "$_cli_default/manifest/enforcement.tsv" ]; then
|
|
186
|
+
GOBLIN_ENGINE="$_cli_default"
|
|
187
|
+
MANIFEST="$GOBLIN_ENGINE/manifest/enforcement.tsv"
|
|
188
|
+
CLASSES="$GOBLIN_ENGINE/manifest/classes.tsv"
|
|
189
|
+
else
|
|
190
|
+
MANIFEST="$SELF_DIR/../manifest/enforcement.tsv"
|
|
191
|
+
CLASSES="$SELF_DIR/../manifest/classes.tsv"
|
|
192
|
+
fi
|
|
193
|
+
fi
|
|
194
|
+
[ -f "$MANIFEST" ] || {
|
|
195
|
+
g_err "no manifest at $MANIFEST"
|
|
196
|
+
g_err "no manifest at any of: \$SOURCE (${SOURCE:-unset}), $ENGINE_CHAIN_VENDORED (absent), $SELF_DIR/../manifest/enforcement.tsv"
|
|
197
|
+
exit 2
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
INSTALLED="$ROOT/.goblin/installed.json"
|
|
201
|
+
|
|
202
|
+
# engine_block_field <key> — one field of installed.json's engine: block (empty if absent).
|
|
203
|
+
engine_block_field() {
|
|
204
|
+
[ -f "$INSTALLED" ] || return 0
|
|
205
|
+
g_json_object "$INSTALLED" engine | awk -F'\t' -v k="$1" '$1==k {print $2; exit}'
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
# ---- the engine mode (W1 §2.2) ----------------------------------------------
|
|
209
|
+
# vendored (the default): the engine resolved inside the repo (or --source pinned it)
|
|
210
|
+
# and installed.json records the payload. The install-record and procedure rows
|
|
211
|
+
# behave exactly as before W1.
|
|
212
|
+
# global: the engine resolved OUTSIDE the repo (engine_dir:, GOBLIN_ENGINE_DIR or
|
|
213
|
+
# ~/.goblin/engine), or installed.json's own engine: block says mode=global - a repo
|
|
214
|
+
# whose engine: block says global is global even if a vendored payload is still on
|
|
215
|
+
# disk mid-migration (W3's three-verify sequence). The repo-local clauses keep
|
|
216
|
+
# hashing what IS recorded; absence of engine files is not drift; a repo with no
|
|
217
|
+
# install record at all is not born RED.
|
|
218
|
+
ENGINE_MODE="vendored"
|
|
219
|
+
[ -n "$GOBLIN_ENGINE" ] && ENGINE_MODE="global"
|
|
220
|
+
_em=$(engine_block_field mode)
|
|
221
|
+
[ "$_em" = "global" ] && ENGINE_MODE="global"
|
|
222
|
+
export ENGINE_MODE
|
|
223
|
+
|
|
224
|
+
# ---- manifest integrity (exit 3 before any check runs) ----------------------
|
|
225
|
+
manifest_check() {
|
|
226
|
+
local rc=0
|
|
227
|
+
awk -F'\t' 'NR>1 && $6=="" {print "row " $1 " has no check and no advisory label"; bad=1} END{exit bad}' "$MANIFEST" || rc=3
|
|
228
|
+
awk -F'\t' 'NR>1 {if (seen[$1]++) {print "duplicate id " $1; bad=1}} END{exit bad}' "$MANIFEST" || rc=3
|
|
229
|
+
return $rc
|
|
230
|
+
}
|
|
231
|
+
manifest_check || { g_err "manifest is broken (exit 3)"; exit 3; }
|
|
232
|
+
|
|
233
|
+
if [ "$LIST" -eq 1 ]; then
|
|
234
|
+
awk -F'\t' 'NR>1 {printf "%-7s %-7s %-9s %s\n", $1, $2, $4, $3}' "$MANIFEST"
|
|
235
|
+
exit 0
|
|
236
|
+
fi
|
|
237
|
+
|
|
238
|
+
# --only must name rows the manifest actually has: a typo used to select nothing, emit no
|
|
239
|
+
# rows and exit 0, turning a pipeline gate into a no-op (D15).
|
|
240
|
+
if [ -n "$ONLY" ]; then
|
|
241
|
+
UNKNOWN=""
|
|
242
|
+
for want in $(printf '%s' "$ONLY" | tr ',' ' '); do
|
|
243
|
+
[ -n "$want" ] || continue
|
|
244
|
+
awk -F'\t' -v x="$want" 'NR>1 && $1==x {f=1} END{exit !f}' "$MANIFEST" \
|
|
245
|
+
|| UNKNOWN="$UNKNOWN $want"
|
|
246
|
+
done
|
|
247
|
+
[ -z "$UNKNOWN" ] || { g_err "--only names id(s) not in the manifest:$UNKNOWN"; exit 2; }
|
|
248
|
+
# A valid id that is SOURCE-scope (PR-01..PR-04) selects no target row: the run emits
|
|
249
|
+
# nothing and exits 0, which is the same no-op gate D15 closed for a typo (F2-6).
|
|
250
|
+
TARGET_ROWS=0
|
|
251
|
+
for want in $(printf '%s' "$ONLY" | tr ',' ' '); do
|
|
252
|
+
[ -n "$want" ] || continue
|
|
253
|
+
awk -F'\t' -v x="$want" 'NR>1 && $1==x && $2=="target" {f=1} END{exit !f}' "$MANIFEST" \
|
|
254
|
+
&& TARGET_ROWS=1
|
|
255
|
+
done
|
|
256
|
+
if [ "$TARGET_ROWS" -eq 0 ]; then
|
|
257
|
+
g_err "--only $ONLY selects no target-scope row, so this run would execute nothing and"
|
|
258
|
+
g_err "report success. Source-scope rows (PR-*) run in tests/run-tests.sh, not here."
|
|
259
|
+
exit 2
|
|
260
|
+
fi
|
|
261
|
+
fi
|
|
262
|
+
|
|
263
|
+
if [ ! -f "$CONFIG" ]; then
|
|
264
|
+
g_err "not installed: $CONFIG is absent. Run: goblin-install --target $ROOT --class <A|B|C|D|E>"
|
|
265
|
+
exit 2
|
|
266
|
+
fi
|
|
267
|
+
|
|
268
|
+
CLASS=$(g_yaml_scalar "$CONFIG" class)
|
|
269
|
+
case "$CLASS" in
|
|
270
|
+
A|B|C|D|E|F) ;;
|
|
271
|
+
*) g_err "unknown class '${CLASS:-<empty>}' in $CONFIG (expected A|B|C|D|E|F)"; exit 2 ;;
|
|
272
|
+
esac
|
|
273
|
+
|
|
274
|
+
# Root vs git toplevel. find_installed_root resolves the CONFIG lookup correctly, but the
|
|
275
|
+
# rows that shell out to git run in $ROOT and git resolves that to the ENCLOSING repo when
|
|
276
|
+
# the target is not a repository of its own: a nested target then reports the outer repo's
|
|
277
|
+
# index (30 "dirty entries" that belong to the outer repo) instead of its own state (F2-1).
|
|
278
|
+
# Refuse up front, with the reason, rather than print a number about the wrong tree.
|
|
279
|
+
ROOT_PHYS=$(cd "$ROOT" && pwd -P)
|
|
280
|
+
GIT_TOP=$(cd "$ROOT" && git rev-parse --show-toplevel 2>/dev/null)
|
|
281
|
+
if [ -n "$GIT_TOP" ] && [ "$GIT_TOP" != "$ROOT_PHYS" ]; then
|
|
282
|
+
g_err "target $ROOT_PHYS is inside the git repository $GIT_TOP but is not a repository of"
|
|
283
|
+
g_err "its own. CM-01 CM-03 GT-03 SP-02 PG-01 PG-03 DS-01 PT-02 and HP-05 shell out to git,"
|
|
284
|
+
g_err "and git would resolve every one of them to $GIT_TOP - the numbers would describe the"
|
|
285
|
+
g_err "outer repo, not this target."
|
|
286
|
+
g_err "fix: cd $ROOT_PHYS && git init (make the target its own repo), or move it out of"
|
|
287
|
+
g_err " $GIT_TOP."
|
|
288
|
+
exit 2
|
|
289
|
+
fi
|
|
290
|
+
|
|
291
|
+
BRANCH=$(g_yaml_scalar "$CONFIG" branch)
|
|
292
|
+
ARCHIVE=$(g_yaml_scalar "$CONFIG" archive)
|
|
293
|
+
MAX_DIRTY=$(g_yaml_scalar "$CONFIG" max_dirty)
|
|
294
|
+
ADV_CEILING=$(g_yaml_scalar "$CONFIG" advisory_ceiling)
|
|
295
|
+
HARNESS_DIR_RAW=$(g_yaml_scalar "$CONFIG" harness_dir)
|
|
296
|
+
SCAFFOLD_CHECKS=$(g_yaml_scalar "$CONFIG" scaffold_checks)
|
|
297
|
+
MODELS_FILE=$(g_expand_tilde "$(g_yaml_scalar "$CONFIG" models_file)")
|
|
298
|
+
PRACTICE=$(g_expand_tilde "$(g_yaml_scalar "$CONFIG" practice)")
|
|
299
|
+
PRACTICE_SHA=$(g_unquote "$(g_yaml_scalar "$CONFIG" practice_sha256)")
|
|
300
|
+
[ -n "$MAX_DIRTY" ] || MAX_DIRTY=0
|
|
301
|
+
[ -n "$ADV_CEILING" ] || ADV_CEILING=10
|
|
302
|
+
[ -n "$HARNESS_DIR_RAW" ] || HARNESS_DIR_RAW=checks
|
|
303
|
+
# A config installed before this key existed declares no harness expectation: keep the old
|
|
304
|
+
# skip behaviour rather than failing an adopted repo on an upgrade.
|
|
305
|
+
[ -n "$SCAFFOLD_CHECKS" ] || SCAFFOLD_CHECKS=no
|
|
306
|
+
|
|
307
|
+
# The feature map (G1). feature_map: EMPTY is the meaningful state, not a broken config: a
|
|
308
|
+
# fresh install has no map and the two FM rows must SKIP with that reason (the replay.commit:
|
|
309
|
+
# "" shape, docs/LIMITS.md #11). source_root: is what an entry path resolves against.
|
|
310
|
+
FEATURE_MAP=$(g_yaml_scalar "$CONFIG" feature_map)
|
|
311
|
+
SOURCE_ROOT=$(g_unquote "$(g_yaml_scalar "$CONFIG" source_root)")
|
|
312
|
+
[ -n "$SOURCE_ROOT" ] || SOURCE_ROOT=.
|
|
313
|
+
VERIFY_DOCTOR=$(g_unquote "$(g_yaml_scalar "$CONFIG" verify_doctor)")
|
|
314
|
+
export FEATURE_MAP SOURCE_ROOT VERIFY_DOCTOR
|
|
315
|
+
|
|
316
|
+
# The reference corpus (P15, RC-01..RC-04). `reference_manifest:` is the ONE state that may SKIP
|
|
317
|
+
# with a reason: a repo with no reference corpus must not be born RED (the feature_map: shape),
|
|
318
|
+
# and RC-01/RC-02/RC-04 skip for exactly that reason. A key that IS declared but whose file is
|
|
319
|
+
# missing or unparseable is a FAIL, deliberately, so losing the manifest cannot read as "no
|
|
320
|
+
# corpus" (the FM-01 declared-map-absent shape). These rows are builtins and not shell one-liners
|
|
321
|
+
# for this reason alone: the runner gives only a builtin exit 2/3 (see the dispatch below), so a
|
|
322
|
+
# one-liner could never report "not applicable yet" without failing the run.
|
|
323
|
+
# `quarantine_root:` is the quarantine directory of the playbook's S5 - outside the repo by
|
|
324
|
+
# design - and no row reads it; it is declared so the procedure has a machine-readable anchor
|
|
325
|
+
# instead of a path in prose (it ships empty here, the way feature_map: does).
|
|
326
|
+
REFERENCE_MANIFEST=$(g_unquote "$(g_yaml_scalar "$CONFIG" reference_manifest)")
|
|
327
|
+
export REFERENCE_MANIFEST
|
|
328
|
+
|
|
329
|
+
# The loop ceiling (G2, LP-03). The most turns ONE loop record may declare in its budget; 20 is
|
|
330
|
+
# the engine's own default for a kanban goal loop, so a ceiling that disagreed with the engine
|
|
331
|
+
# would be a number someone made up. A config written before this key existed keeps the default
|
|
332
|
+
# (the SCAFFOLD_CHECKS shape): an absent key is not a broken config.
|
|
333
|
+
LOOP_MAX_TURNS_CEILING=$(g_yaml_scalar "$CONFIG" loop_max_turns_ceiling)
|
|
334
|
+
[ -n "$LOOP_MAX_TURNS_CEILING" ] || LOOP_MAX_TURNS_CEILING=20
|
|
335
|
+
# The loop record (G2): a COMMITTED artifact of one unattended run. Nothing in this section
|
|
336
|
+
# re-runs the predicate and nothing writes: the record IS the evidence, and what that costs is
|
|
337
|
+
# stated in docs/LOOP.md and docs/LIMITS.md.
|
|
338
|
+
LOOP_DIR="$ROOT/.goblin/loop"
|
|
339
|
+
export LOOP_MAX_TURNS_CEILING LOOP_DIR
|
|
340
|
+
|
|
341
|
+
if [ -d "$ROOT/$HARNESS_DIR_RAW" ]; then
|
|
342
|
+
export GOBLIN_HARNESS_DIR="$ROOT/$HARNESS_DIR_RAW"
|
|
343
|
+
export GOBLIN_HARNESS_REL="$HARNESS_DIR_RAW"
|
|
344
|
+
export GOBLIN_HARNESS_STATE=present
|
|
345
|
+
else
|
|
346
|
+
export GOBLIN_HARNESS_DIR=""
|
|
347
|
+
export GOBLIN_HARNESS_REL="$HARNESS_DIR_RAW"
|
|
348
|
+
# 'absent' = the class scaffolds a harness dir and the declared one is missing -> FAIL.
|
|
349
|
+
# 'none' = the class ships no harness dir -> SKIP with a reason (D7).
|
|
350
|
+
if [ "$SCAFFOLD_CHECKS" = "yes" ]; then
|
|
351
|
+
export GOBLIN_HARNESS_STATE=absent
|
|
352
|
+
else
|
|
353
|
+
export GOBLIN_HARNESS_STATE=none
|
|
354
|
+
fi
|
|
355
|
+
fi
|
|
356
|
+
export GOBLIN_ROOT="$ROOT"
|
|
357
|
+
export GOBLIN_CONFIG="$CONFIG"
|
|
358
|
+
|
|
359
|
+
# ---- the security surface (SC-01..SC-09) and the perf baseline (PF-01) -----------------------
|
|
360
|
+
# Every one of these is DECLARED in .goblin/goblin.yaml. A stack-specific rule guessed from the
|
|
361
|
+
# files on disk is how a matrix starts lying; a declared key that is absent is a skip with a
|
|
362
|
+
# reason, never a silent pass.
|
|
363
|
+
SEC_GITIGNORE_FAMILY=$(g_yaml_block_scalar "$CONFIG" security gitignore_family)
|
|
364
|
+
SEC_BUILD_OUTPUT=$(g_yaml_block_scalar "$CONFIG" security build_output)
|
|
365
|
+
SEC_AUDIT_CMD=$(g_yaml_block_scalar "$CONFIG" security audit_cmd)
|
|
366
|
+
SEC_AUDIT_MAX_AGE=$(g_yaml_block_scalar "$CONFIG" security audit_max_age_days)
|
|
367
|
+
SEC_WAIVER_MAX_AGE=$(g_yaml_block_scalar "$CONFIG" security waiver_max_age_days)
|
|
368
|
+
SEC_WRITE_ROUTES=$(g_yaml_block_scalar "$CONFIG" security write_routes)
|
|
369
|
+
[ -n "$SEC_AUDIT_MAX_AGE" ] || SEC_AUDIT_MAX_AGE=90
|
|
370
|
+
[ -n "$SEC_WAIVER_MAX_AGE" ] || SEC_WAIVER_MAX_AGE=180
|
|
371
|
+
PERF_METRIC=$(g_yaml_block_scalar "$CONFIG" perf metric)
|
|
372
|
+
PERF_CMD=$(g_yaml_block_scalar "$CONFIG" perf cmd)
|
|
373
|
+
PERF_BASELINE_COMMIT=$(g_unquote "$(g_yaml_block_scalar "$CONFIG" perf baseline_commit)")
|
|
374
|
+
PERF_BASELINE_VALUE=$(g_yaml_block_scalar "$CONFIG" perf baseline_value)
|
|
375
|
+
PERF_MEASURED=$(g_unquote "$(g_yaml_block_scalar "$CONFIG" perf measured)")
|
|
376
|
+
PERF_HOST_GATE=$(g_yaml_block_scalar "$CONFIG" perf host_gate)
|
|
377
|
+
export SEC_GITIGNORE_FAMILY SEC_BUILD_OUTPUT SEC_AUDIT_CMD SEC_AUDIT_MAX_AGE SEC_WAIVER_MAX_AGE
|
|
378
|
+
export SEC_WRITE_ROUTES PERF_METRIC PERF_CMD PERF_BASELINE_VALUE PERF_HOST_GATE
|
|
379
|
+
|
|
380
|
+
# Whole days between an ISO date and today, or empty when the date does not parse on this host
|
|
381
|
+
# (`date -d` is GNU; the row FAILS rather than assuming, because an unreadable date is not a
|
|
382
|
+
# fresh record).
|
|
383
|
+
age_days() {
|
|
384
|
+
local then_s now_s
|
|
385
|
+
then_s=$(date -u -d "$1" +%s 2>/dev/null) || return 1
|
|
386
|
+
now_s=$(date -u +%s)
|
|
387
|
+
printf '%s' "$(( (now_s - then_s) / 86400 ))"
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
# The source files a secret-shaped search should look at. The vendored harness (.goblin,
|
|
391
|
+
# .hermes), the build output and dependencies are excluded: SC-03 must not match the row text
|
|
392
|
+
# that describes it.
|
|
393
|
+
sc_sources() {
|
|
394
|
+
( cd "$ROOT" && grep -rniE "$1" \
|
|
395
|
+
--include='*.ts' --include='*.tsx' --include='*.js' --include='*.jsx' \
|
|
396
|
+
--include='*.mjs' --include='*.cjs' --include='*.vue' --include='*.svelte' . 2>/dev/null \
|
|
397
|
+
| grep -vE '(^|/)(\.next|node_modules|\.goblin|\.hermes|dist|build|out|coverage)/' || true )
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
only_selected() {
|
|
401
|
+
[ -z "$ONLY" ] && return 0
|
|
402
|
+
case ",$ONLY," in *",$1,"*) return 0 ;; esac
|
|
403
|
+
return 1
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
# ---- part mapping: a row id's prefix names the class-controlled part ---------
|
|
407
|
+
row_part() {
|
|
408
|
+
case "$1" in
|
|
409
|
+
HP-*) echo handoff ;;
|
|
410
|
+
SP-*) echo spec ;;
|
|
411
|
+
GT-01|GT-02|GT-03) echo gate ;;
|
|
412
|
+
GT-04|GT-05) echo ratchet ;;
|
|
413
|
+
HS-01|HS-03) echo gate ;;
|
|
414
|
+
HS-02) echo replay ;;
|
|
415
|
+
PG-*) echo pr-gate ;;
|
|
416
|
+
# The automation rows and SK-04 ride the `playbooks` part: their artifacts are the shipped
|
|
417
|
+
# skills and the automation producers, and --skills no disables that part. An unmapped
|
|
418
|
+
# prefix returns "" and the row can never honour an opt-out (G3 section 4.3).
|
|
419
|
+
AU-*) echo playbooks ;;
|
|
420
|
+
SK-04) echo playbooks ;;
|
|
421
|
+
# SK-01 rides the same part (W1 made it a builtin for the global-mode clause): with
|
|
422
|
+
# --skills no there is no shipped skill to frontmatter-check, and a vacuous pass proves
|
|
423
|
+
# nothing - the row honours the opt-out like its SK-02/SK-04 siblings.
|
|
424
|
+
SK-01) echo playbooks ;;
|
|
425
|
+
# SK-02 keys off the same part: with --skills no there are no installed skills to hash, and
|
|
426
|
+
# the row used to FAIL there (measured at v0.2.0-dev: "32 passed, 1 failed ... SK-02 0
|
|
427
|
+
# installed skill file(s) hashed"). An opt-out is the honest report; a FAIL on a repo that
|
|
428
|
+
# opted out of skills is not.
|
|
429
|
+
SK-02) echo playbooks ;;
|
|
430
|
+
*) echo "" ;;
|
|
431
|
+
esac
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
# ============================================================== builtins ======
|
|
435
|
+
# Each builtin prints its detail and returns 0 pass | 1 fail | 2 advisory | 3 skip.
|
|
436
|
+
|
|
437
|
+
# The install-record rows (W1 §2.5). IN-01 and IN-04 were shell one-liners over
|
|
438
|
+
# .goblin/installed.json; the engine.mode=global clause needs engine logic (a global,
|
|
439
|
+
# declaration-only repo has NO install record and must SKIP with the reason, never be
|
|
440
|
+
# born RED - the D8/FM-01 shape), so the one-liners move into builtins that keep the
|
|
441
|
+
# same clauses and gain the mode check. In vendored mode they are exactly the old cells.
|
|
442
|
+
check_in_01() {
|
|
443
|
+
if [ "$ENGINE_MODE" = "global" ] && [ ! -s "$INSTALLED" ]; then
|
|
444
|
+
printf 'global engine mode — no per-repo install record\n'
|
|
445
|
+
return 3
|
|
446
|
+
fi
|
|
447
|
+
[ -s "$INSTALLED" ] && grep -q '"version"' "$INSTALLED" \
|
|
448
|
+
&& { printf 'the install record exists and names its version\n'; return 0; }
|
|
449
|
+
printf 'installed.json is absent or records no version\n'
|
|
450
|
+
return 1
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
# g_installed_files_strict <installed.json> — only the entries of the `files:` MAP. The
|
|
454
|
+
# line-oriented engine-block reader (g_json_object) also matches the `engine:` block's
|
|
455
|
+
# "key": "value" pairs (W1 §2.2), which IN-04 must never read as pre-existing files.
|
|
456
|
+
g_installed_files_strict() {
|
|
457
|
+
awk '
|
|
458
|
+
/^ "files": \{[[:space:]]*\},?$/ { next }
|
|
459
|
+
/^ "files": \{/ { f = 1; next }
|
|
460
|
+
f && /^ \},$/ { f = 0 }
|
|
461
|
+
f && /^ "/ {
|
|
462
|
+
line = $0
|
|
463
|
+
sub(/^ "/, "", line)
|
|
464
|
+
p = line; sub(/".*/, "", p)
|
|
465
|
+
h = line; sub(/[^"]*"[[:space:]]*:[[:space:]]*"/, "", h); sub(/".*/, "", h)
|
|
466
|
+
print p "\t" h
|
|
467
|
+
}
|
|
468
|
+
' "$1"
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
# g_json_object_strict <installed.json> <object-name> — g_json_object, hardened for the
|
|
472
|
+
# engine-block record (W1 §2.2): an EMPTY object (`"refused": {}`) opens and closes on
|
|
473
|
+
# the same line, so the reader must not treat the NEXT block's `"key": "value"` pairs
|
|
474
|
+
# as this object's (IN-04 read the engine block's `mode`/`engine_dir` as vanished
|
|
475
|
+
# pre-existing files).
|
|
476
|
+
g_json_object_strict() {
|
|
477
|
+
awk -v name="$2" '
|
|
478
|
+
index($0, "\"" name "\"") && /:[[:space:]]*\{/ {
|
|
479
|
+
if ($0 ~ /\{[[:space:]]*\}/) next # empty object: nothing inside
|
|
480
|
+
inf = 1; next
|
|
481
|
+
}
|
|
482
|
+
inf && /^[[:space:]]*\}/ { inf = 0 }
|
|
483
|
+
inf && /"/ {
|
|
484
|
+
line = $0
|
|
485
|
+
sub(/^[[:space:]]*"/, "", line)
|
|
486
|
+
p = line; sub(/".*/, "", p)
|
|
487
|
+
h = line; sub(/^[^"]*"[[:space:]]*:[[:space:]]*"/, "", h); sub(/".*/, "", h)
|
|
488
|
+
if (p != "") print p "\t" h
|
|
489
|
+
}
|
|
490
|
+
' "$1"
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
check_in_04() {
|
|
494
|
+
if [ "$ENGINE_MODE" = "global" ] && [ ! -s "$INSTALLED" ]; then
|
|
495
|
+
printf 'global engine mode — no per-repo install record\n'
|
|
496
|
+
return 3
|
|
497
|
+
fi
|
|
498
|
+
local p h n=0 bad=0 files
|
|
499
|
+
files=$(g_installed_files_strict "$INSTALLED" | cut -f1)
|
|
500
|
+
while IFS=$'\t' read -r p h; do
|
|
501
|
+
[ -n "$p" ] || continue
|
|
502
|
+
n=$((n + 1))
|
|
503
|
+
if [ ! -e "$ROOT/$p" ]; then printf 'pre-existing file vanished: %s\n' "$p"; bad=1; continue; fi
|
|
504
|
+
if printf '%s\n' "$files" | grep -qxF "$p"; then
|
|
505
|
+
printf 'pre-existing file was taken over by the install: %s\n' "$p"; bad=1
|
|
506
|
+
fi
|
|
507
|
+
done < <(g_json_object_strict "$INSTALLED" refused)
|
|
508
|
+
printf '%d pre-existing file(s) left untouched\n' "$n"
|
|
509
|
+
[ "$bad" -eq 0 ]
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
check_in_02() {
|
|
513
|
+
# The W1 semantic split (§2.2). Clause 1 - the per-repo mode: hash every recorded file,
|
|
514
|
+
# the practice pin rides along - is byte-for-byte what this row has always done, and a
|
|
515
|
+
# fresh install of any class keeps it (installed.json carries no engine: block).
|
|
516
|
+
# Clause 2 - engine.mode=global with file entries remaining: hash what IS recorded;
|
|
517
|
+
# an entry under the engine paths (.goblin/bin, .goblin/manifest, .goblin/bans,
|
|
518
|
+
# .goblin/automations, .goblin/roles.yaml) that is still present is hashed too
|
|
519
|
+
# (harmless), and one that is MISSING is not drift - the engine is no longer the repo's.
|
|
520
|
+
# Clause 3 - global with no file entries: the declaration + practice pin only; never
|
|
521
|
+
# FAIL for absence of install artifacts (a declaration-only repo is not born RED).
|
|
522
|
+
# The engine hash itself is printed in the footer on every run (see the footer, below);
|
|
523
|
+
# this row proves the REPO-LOCAL bytes, and a global engine answers elsewhere.
|
|
524
|
+
local p h cur n=0 bad=0 pin="" detail=""
|
|
525
|
+
while IFS=$'\t' read -r p h; do
|
|
526
|
+
[ -n "$p" ] || continue
|
|
527
|
+
if [ "$ENGINE_MODE" = "global" ]; then
|
|
528
|
+
case "$p" in
|
|
529
|
+
.goblin/bin/*|.goblin/manifest/*|.goblin/bans/*|.goblin/automations/*|.goblin/roles.yaml)
|
|
530
|
+
[ -e "$ROOT/$p" ] || continue ;; # engine removed by design: not drift
|
|
531
|
+
# W4a (§5.3/§5.2): the procedure tier is EMITTED per platform, not carried in the
|
|
532
|
+
# repo - a recorded .hermes/skills/ file absent from disk is the tier living at its
|
|
533
|
+
# emitted paths, not drift. A copy that IS present must still hash true (a stale or
|
|
534
|
+
# hand-edited shadow is exactly what --unshadow/doctor exist to surface).
|
|
535
|
+
.hermes/skills/*)
|
|
536
|
+
[ -e "$ROOT/$p" ] || continue ;;
|
|
537
|
+
esac
|
|
538
|
+
fi
|
|
539
|
+
n=$((n + 1))
|
|
540
|
+
cur=$(g_sha256_file "$ROOT/$p")
|
|
541
|
+
if [ "$cur" != "$h" ]; then
|
|
542
|
+
printf 'drift: %s recorded %s now %s\n' "$p" "$h" "$cur"
|
|
543
|
+
bad=1
|
|
544
|
+
fi
|
|
545
|
+
done < <(g_installed_files_strict "$INSTALLED")
|
|
546
|
+
if [ "$ENGINE_MODE" = "global" ] && [ "$n" -eq 0 ]; then
|
|
547
|
+
detail="0 repo-local files hashed (global engine mode)"
|
|
548
|
+
else
|
|
549
|
+
[ "$n" -gt 0 ] || { printf 'installed.json lists no files\n'; return 1; }
|
|
550
|
+
fi
|
|
551
|
+
# The practice pin rides on this row (R6 section 2.1 requires the pin; section 5 has no row of
|
|
552
|
+
# its own for it). Both are the same statement: a recorded hash still matches the file on disk.
|
|
553
|
+
local pin=""
|
|
554
|
+
if [ -n "$PRACTICE" ] && [ -n "$PRACTICE_SHA" ]; then
|
|
555
|
+
if [ ! -e "$PRACTICE" ]; then
|
|
556
|
+
printf 'practice standard not found: %s\n' "$PRACTICE"; bad=1; pin=" | practice missing"
|
|
557
|
+
else
|
|
558
|
+
cur=$(g_sha256_file "$PRACTICE")
|
|
559
|
+
if [ "$cur" != "$PRACTICE_SHA" ]; then
|
|
560
|
+
printf 'practice standard edited: %s\n recorded %s\n now %s\n' "$PRACTICE" "$PRACTICE_SHA" "$cur"
|
|
561
|
+
# The remedy, in the place the operator actually meets the failure. The pin exists to
|
|
562
|
+
# catch a SILENT edit, so re-pinning is a deliberate act: one explicit command, which
|
|
563
|
+
# prints the old and new hash (docs/CONTRACTS.md, "An edited standard is not a dead end").
|
|
564
|
+
printf ' if the edit is yours and intended, re-pin it deliberately:\n'
|
|
565
|
+
printf ' goblin-install --target %s --re-pin\n' "$ROOT"
|
|
566
|
+
bad=1; pin=" | practice EDITED"
|
|
567
|
+
else
|
|
568
|
+
pin=" | practice pin ok"
|
|
569
|
+
fi
|
|
570
|
+
fi
|
|
571
|
+
fi
|
|
572
|
+
if [ "$ENGINE_MODE" = "global" ] && [ "$n" -eq 0 ]; then
|
|
573
|
+
printf '%s%s\n' "$detail" "${pin:+ | ${pin# | }}"
|
|
574
|
+
[ "$bad" -eq 0 ]
|
|
575
|
+
return
|
|
576
|
+
fi
|
|
577
|
+
printf '%d installed files hashed%s\n' "$n" "$pin"
|
|
578
|
+
[ "$bad" -eq 0 ]
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
check_hp_03() {
|
|
582
|
+
# Every gate-bearing line inside the HANDOFF's `Gates` section carries `measured <date>`.
|
|
583
|
+
# Anchored on the gate names `.goblin/goblin.yaml` DECLARES (GT-01's source of truth), not on
|
|
584
|
+
# a hardcoded keyword list: the shipped gates are named `commit` and `todo_ceiling`, and the
|
|
585
|
+
# old list `(tsc|build|hex|safelist|n/n)` matched neither, so on a fresh install the only line
|
|
586
|
+
# it could see was the template's own "Example of the required form" prose. Measured at
|
|
587
|
+
# 43f7f69: strip the date from the REAL gate line -> PASS; strip it from the example -> FAIL.
|
|
588
|
+
# The row was testing the template, not the artifact (G8-2).
|
|
589
|
+
#
|
|
590
|
+
# A gate line is a line inside the section that carries `=` and either names a declared gate or
|
|
591
|
+
# carries a gate-shaped number. A line whose text says "example of the required form" is
|
|
592
|
+
# template prose, not a gate number, and is skipped. n == 0 is a FAIL: a Gates section with no
|
|
593
|
+
# gate line at all (which is what is left when only the example remains) is not a green row.
|
|
594
|
+
local names out rc n detail
|
|
595
|
+
names=$(g_yaml_gates "$CONFIG" | cut -f1 | tr '\n' ' ')
|
|
596
|
+
names=${names% }
|
|
597
|
+
rc=0
|
|
598
|
+
out=$(awk -v names="$names" '
|
|
599
|
+
function hasname(line, k, i, a, m) {
|
|
600
|
+
m = split(line, a, /[^A-Za-z0-9_./-]+/)
|
|
601
|
+
for (i = 1; i <= m; i++) if (a[i] == k) return 1
|
|
602
|
+
return 0
|
|
603
|
+
}
|
|
604
|
+
BEGIN {
|
|
605
|
+
bad = 0; n = 0
|
|
606
|
+
m = split(names, dn, " ")
|
|
607
|
+
for (i = 1; i <= m; i++) if (dn[i] != "") decl[dn[i]] = 1
|
|
608
|
+
}
|
|
609
|
+
tolower($0) ~ /^#{2,3}[[:space:]]+[^[:alpha:]]*gates?([^[:alpha:]]|$)/ { g = 1; next }
|
|
610
|
+
g && /^#{1,3}[[:space:]]/ { g = 0 }
|
|
611
|
+
!g { next }
|
|
612
|
+
tolower($0) ~ /example of the required form/ { next }
|
|
613
|
+
$0 !~ /=/ { next }
|
|
614
|
+
{
|
|
615
|
+
hit = 0
|
|
616
|
+
if ($0 ~ /(tsc|build|hex|safelist|[0-9]+\/[0-9]+)[^=]*=/) hit = 1
|
|
617
|
+
if (!hit) for (k in decl) if (hasname($0, k)) { hit = 1; break }
|
|
618
|
+
if (hit) {
|
|
619
|
+
n++
|
|
620
|
+
if ($0 !~ /measured [0-9]{4}-[0-9]{2}-[0-9]{2}/) {
|
|
621
|
+
print "a gate line carries no measured date: " $0
|
|
622
|
+
bad = 1
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
END {
|
|
627
|
+
if (n == 0) {
|
|
628
|
+
print "no gate line inside the Gates section (declared gates: " (names == "" ? "none" : names) ")"
|
|
629
|
+
bad = 1
|
|
630
|
+
}
|
|
631
|
+
printf "#count %d\n", n
|
|
632
|
+
exit bad
|
|
633
|
+
}
|
|
634
|
+
' "$ROOT/HANDOFF.md") || rc=$?
|
|
635
|
+
n=$(printf '%s\n' "$out" | sed -n 's/^#count //p')
|
|
636
|
+
detail=$(printf '%s\n' "$out" | grep -v '^#count ' || true)
|
|
637
|
+
if [ "$rc" -ne 0 ]; then printf '%s\n' "$detail"; return 1; fi
|
|
638
|
+
printf '%d gate line(s) inside the Gates section, each with a measured date%s\n' \
|
|
639
|
+
"${n:-0}" "${names:+ (anchored on the declared gate name(s): $names)}"
|
|
640
|
+
return 0
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
check_hp_05() {
|
|
644
|
+
local tok found=0 head
|
|
645
|
+
head=$(cd "$ROOT" && git rev-parse HEAD 2>/dev/null)
|
|
646
|
+
for tok in $(grep -oE '[0-9a-f]{7,40}' "$ROOT/HANDOFF.md" 2>/dev/null | sort -u); do
|
|
647
|
+
( cd "$ROOT" && git rev-parse --verify --quiet "$tok^{commit}" >/dev/null 2>&1 ) || continue
|
|
648
|
+
( cd "$ROOT" && git merge-base --is-ancestor "$tok" HEAD 2>/dev/null ) || continue
|
|
649
|
+
printf 'names %s (ancestor of HEAD %s)\n' "$tok" "${head:0:7}"
|
|
650
|
+
found=1
|
|
651
|
+
break
|
|
652
|
+
done
|
|
653
|
+
[ "$found" -eq 1 ] || printf 'HANDOFF.md names no commit that exists in this repo\n'
|
|
654
|
+
[ "$found" -eq 1 ]
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
check_gt_01() {
|
|
658
|
+
# Every `- name:` under `gates:` is a DECLARED gate, and every declared gate must carry a
|
|
659
|
+
# `cmd:`. Counting only the name+cmd PAIRS (what this row did) let a gate lose its `cmd:` -
|
|
660
|
+
# deleted, blanked or re-indented - and the row still reported the survivors as
|
|
661
|
+
# `1 declared gate(s)`: the gate vanished in silence (G8-3). The name reader is the one that
|
|
662
|
+
# keeps it visible, so the missing `cmd:` is a FAIL here, not a pass.
|
|
663
|
+
local n=0 bad=0 name cmd
|
|
664
|
+
while IFS=$'\t' read -r name cmd; do
|
|
665
|
+
[ -n "$name" ] || continue
|
|
666
|
+
n=$((n + 1))
|
|
667
|
+
if [ -z "$cmd" ]; then
|
|
668
|
+
printf 'declared gate "%s" has no cmd - it is declared and never runs\n' "$name"
|
|
669
|
+
bad=1
|
|
670
|
+
fi
|
|
671
|
+
done < <(g_yaml_gates_all "$CONFIG")
|
|
672
|
+
[ "$n" -ge 1 ] || { printf 'no gate declared in %s\n' "$CONFIG"; return 1; }
|
|
673
|
+
[ "$bad" -eq 0 ] || return 1
|
|
674
|
+
printf '%d declared gate(s), each with a cmd\n' "$n"
|
|
675
|
+
return 0
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
# run the declared gates; optionally write the one-line measured numbers
|
|
679
|
+
run_gates() {
|
|
680
|
+
local write_line="$1" name cmd rc bad=0 line=""
|
|
681
|
+
while IFS=$'\t' read -r name cmd; do
|
|
682
|
+
[ -n "$name" ] || continue
|
|
683
|
+
( cd "$ROOT" && bash -c "$cmd" ) >/dev/null 2>&1
|
|
684
|
+
rc=$?
|
|
685
|
+
line="$line $name=$rc"
|
|
686
|
+
[ "$rc" -eq 0 ] || { printf 'gate %s: %s -> exit %d\n' "$name" "$cmd" "$rc"; bad=1; }
|
|
687
|
+
done < <(g_yaml_gates "$CONFIG")
|
|
688
|
+
if [ "$write_line" = "yes" ]; then
|
|
689
|
+
local rname rcmd rval
|
|
690
|
+
rname=$(g_yaml_block_scalar "$CONFIG" ratchet name)
|
|
691
|
+
rcmd=$(g_yaml_block_scalar "$CONFIG" ratchet cmd)
|
|
692
|
+
if [ -n "$rname" ] && [ -n "$rcmd" ]; then
|
|
693
|
+
rval=$( cd "$ROOT" && bash -c "$rcmd" 2>/dev/null | tr -d '[:space:]' )
|
|
694
|
+
line="$line · $rname $rval (ceiling $(g_yaml_block_scalar "$CONFIG" ratchet ceiling))"
|
|
695
|
+
fi
|
|
696
|
+
printf '%s · measured %s\n' "${line# }" "$(date +%F)" > "$ROOT/.goblin/last-gate-line"
|
|
697
|
+
cat "$ROOT/.goblin/last-gate-line"
|
|
698
|
+
fi
|
|
699
|
+
return $bad
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
check_gt_02() { run_gates yes; }
|
|
703
|
+
|
|
704
|
+
check_gt_04() {
|
|
705
|
+
local n c ceil
|
|
706
|
+
n=$(g_yaml_block_scalar "$CONFIG" ratchet name)
|
|
707
|
+
c=$(g_yaml_block_scalar "$CONFIG" ratchet cmd)
|
|
708
|
+
ceil=$(g_yaml_block_scalar "$CONFIG" ratchet ceiling)
|
|
709
|
+
[ -n "$n" ] || { printf 'ratchet.name missing\n'; return 1; }
|
|
710
|
+
[ -n "$c" ] || { printf 'ratchet.cmd missing\n'; return 1; }
|
|
711
|
+
case "$ceil" in ''|*[!0-9]*) printf 'ratchet.ceiling must be numeric, got %s\n' "$ceil"; return 1 ;; esac
|
|
712
|
+
printf 'ratchet %s ceiling %s\n' "$n" "$ceil"
|
|
713
|
+
return 0
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
check_gt_05() {
|
|
717
|
+
local n c ceil val old prev="" note=""
|
|
718
|
+
n=$(g_yaml_block_scalar "$CONFIG" ratchet name)
|
|
719
|
+
c=$(g_yaml_block_scalar "$CONFIG" ratchet cmd)
|
|
720
|
+
ceil=$(g_yaml_block_scalar "$CONFIG" ratchet ceiling)
|
|
721
|
+
[ -n "$n" ] && [ -n "$c" ] || { printf 'no ratchet declared for this class - skipped\n'; return 3; }
|
|
722
|
+
val=$( cd "$ROOT" && bash -c "$c" 2>/dev/null | tr -d '[:space:]' )
|
|
723
|
+
case "$val" in ''|*[!0-9]*) printf 'ratchet cmd produced a non-number: %s\n' "$val"; return 1 ;; esac
|
|
724
|
+
if [ -f "$ROOT/.goblin/ratchet-last" ]; then prev=$(cat "$ROOT/.goblin/ratchet-last"); fi
|
|
725
|
+
if [ -n "$prev" ] && [ "$prev" -lt "$val" ]; then
|
|
726
|
+
note="old $prev + $((val - prev)) new = $val"
|
|
727
|
+
else
|
|
728
|
+
note="$n $val (ceiling $ceil)"
|
|
729
|
+
fi
|
|
730
|
+
printf '%s\n' "$val" > "$ROOT/.goblin/ratchet-last"
|
|
731
|
+
if [ "$val" -gt "$ceil" ]; then printf '%s over ceiling %s\n' "$note" "$ceil"; return 1; fi
|
|
732
|
+
printf '%s\n' "$note"
|
|
733
|
+
return 0
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
check_hs_01() {
|
|
737
|
+
local d="$GOBLIN_HARNESS_DIR" f base asserting=0 reporting=0 bad=0
|
|
738
|
+
if [ -z "$d" ]; then
|
|
739
|
+
# A declared harness dir that is missing is a FAIL when the class scaffolds one; a class
|
|
740
|
+
# that ships none skips with a reason. Keying the skip off the PATH let a one-line config
|
|
741
|
+
# edit switch both HS rows off (D7).
|
|
742
|
+
if [ "$GOBLIN_HARNESS_STATE" = "absent" ]; then
|
|
743
|
+
printf 'harness_dir %s is declared and class %s scaffolds it, but the directory is absent\n' \
|
|
744
|
+
"$GOBLIN_HARNESS_REL" "$CLASS"
|
|
745
|
+
return 1
|
|
746
|
+
fi
|
|
747
|
+
printf 'no harness dir (%s absent, class %s scaffolds none) - nothing to assert\n' \
|
|
748
|
+
"$GOBLIN_HARNESS_REL" "$CLASS"
|
|
749
|
+
return 3
|
|
750
|
+
fi
|
|
751
|
+
for f in "$d"/*; do
|
|
752
|
+
[ -f "$f" ] || continue
|
|
753
|
+
base=$(basename "$f")
|
|
754
|
+
if grep -q 'assert(' "$f" && grep -q 'process\.exit' "$f"; then
|
|
755
|
+
asserting=$((asserting + 1))
|
|
756
|
+
else
|
|
757
|
+
reporting=$((reporting + 1))
|
|
758
|
+
printf 'not an asserting harness (report utility): %s\n' "$base"
|
|
759
|
+
fi
|
|
760
|
+
done
|
|
761
|
+
printf '%d asserting harness(es), %d report utility(ies)\n' "$asserting" "$reporting"
|
|
762
|
+
[ "$asserting" -gt 0 ] || { printf 'harness dir %s holds no asserting harness\n' "$GOBLIN_HARNESS_REL"; bad=1; }
|
|
763
|
+
[ "$bad" -eq 0 ]
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
check_hs_02() {
|
|
767
|
+
local env cmd commit d tmp bad=0 f rc base name qname pat
|
|
768
|
+
env=$(g_yaml_block_scalar "$CONFIG" replay env)
|
|
769
|
+
cmd=$(g_yaml_block_scalar "$CONFIG" replay cmd)
|
|
770
|
+
commit=$(g_unquote "$(g_yaml_block_scalar "$CONFIG" replay commit)")
|
|
771
|
+
[ -n "$env" ] || { printf 'replay.env not declared\n'; return 1; }
|
|
772
|
+
[ -n "$cmd" ] || { printf 'replay.cmd not declared\n'; return 1; }
|
|
773
|
+
# Z1-4: the DECLARED command has to be what runs the harness. Until this fix it was read only
|
|
774
|
+
# to assert it was non-empty, and the loop below ran `node <file>` itself - so the command and
|
|
775
|
+
# its `{name}` placeholder were decorative (`grep -rn '{name}' tests/` was 0 hits, and setting
|
|
776
|
+
# `replay.cmd: false` changed no verdict). A documented mechanism that never executes is the
|
|
777
|
+
# same species as the regression this repo already shipped once.
|
|
778
|
+
# * a command that does not interpolate {name} cannot be running the harness it names, so
|
|
779
|
+
# nothing is replayed and "every harness was RED" would be a statement about a command
|
|
780
|
+
# that never ran one (the defanged-probe shape, docs/LIMITS.md #28/W5-12);
|
|
781
|
+
# * a command that cannot be executed at all (exit 126/127) FAILS rather than counting as a
|
|
782
|
+
# RED harness - otherwise a typo'd interpreter would make every harness look proven.
|
|
783
|
+
pat='{name}'
|
|
784
|
+
case "$cmd" in
|
|
785
|
+
*"$pat"*) ;;
|
|
786
|
+
*) printf 'replay.cmd declares no %s: %s\n' "$pat" "$cmd"
|
|
787
|
+
printf 'the declared command cannot be running the harness it names, so nothing was replayed\n'
|
|
788
|
+
return 1 ;;
|
|
789
|
+
esac
|
|
790
|
+
if [ -z "$commit" ] || ! git -C "$ROOT" rev-parse --verify --quiet "$commit^{commit}" >/dev/null; then
|
|
791
|
+
printf 'no pinned pre-change commit yet (replay.commit empty or unknown) - REPLAY not provable\n'
|
|
792
|
+
return 3
|
|
793
|
+
fi
|
|
794
|
+
d="$GOBLIN_HARNESS_DIR"
|
|
795
|
+
if [ -z "$d" ]; then
|
|
796
|
+
if [ "$GOBLIN_HARNESS_STATE" = "absent" ]; then
|
|
797
|
+
printf 'harness_dir %s is declared and class %s scaffolds it, but the directory is absent\n' \
|
|
798
|
+
"$GOBLIN_HARNESS_REL" "$CLASS"
|
|
799
|
+
return 1
|
|
800
|
+
fi
|
|
801
|
+
printf 'no harness dir to replay\n'; return 3
|
|
802
|
+
fi
|
|
803
|
+
tmp=$(mktemp -d)
|
|
804
|
+
if ! git -C "$ROOT" worktree add -q --detach "$tmp/wt" "$commit" 2>/dev/null; then
|
|
805
|
+
rm -rf "$tmp"; printf 'cannot create a worktree at %s\n' "$commit"; return 1
|
|
806
|
+
fi
|
|
807
|
+
mkdir -p "$tmp/wt/$GOBLIN_HARNESS_REL"
|
|
808
|
+
cp -a "$d/." "$tmp/wt/$GOBLIN_HARNESS_REL/"
|
|
809
|
+
for f in "$tmp/wt/$GOBLIN_HARNESS_REL"/*.mjs; do
|
|
810
|
+
[ -e "$f" ] || continue
|
|
811
|
+
base=$(basename "$f")
|
|
812
|
+
name=${base%.mjs}
|
|
813
|
+
# Z2-3: the name is SHELL-QUOTED before it is substituted, because it now reaches a shell.
|
|
814
|
+
# This row runs the DECLARED command rather than `node "$f"` (Z1-4), so a file in the harness
|
|
815
|
+
# dir - a name the row does not control - is part of the command text `bash -c` parses.
|
|
816
|
+
# Unquoted, a name carrying `;`, `#`, a space or a glob was read as shell syntax: measured, a
|
|
817
|
+
# file named `z2;true;#.mjs` produced TWO commands and the trailing `true` decided the exit
|
|
818
|
+
# code. The outcome stayed fail-closed (a GREEN "harness" makes the row FAIL), but it is the
|
|
819
|
+
# same surface class as the G8-1 injection, so it is quoted rather than only documented.
|
|
820
|
+
qname=$(printf '%q' "$name")
|
|
821
|
+
( cd "$tmp/wt" && env "$env"="$commit" bash -c "${cmd//$pat/$qname}" ) >/dev/null 2>&1
|
|
822
|
+
rc=$?
|
|
823
|
+
case "$rc" in
|
|
824
|
+
0)
|
|
825
|
+
printf '%s was GREEN on the pre-change tree %s - proves nothing\n' "$base" "${commit:0:7}"
|
|
826
|
+
bad=1
|
|
827
|
+
;;
|
|
828
|
+
126|127)
|
|
829
|
+
printf '%s: the declared replay.cmd could not be executed (exit %d): %s\n' "$base" "$rc" "$cmd"
|
|
830
|
+
bad=1
|
|
831
|
+
;;
|
|
832
|
+
esac
|
|
833
|
+
done
|
|
834
|
+
git -C "$ROOT" worktree remove --force "$tmp/wt" >/dev/null 2>&1
|
|
835
|
+
rm -rf "$tmp"
|
|
836
|
+
[ "$bad" -eq 0 ] && printf 'every harness was RED at %s (run as: %s)\n' "${commit:0:7}" "$cmd"
|
|
837
|
+
return $bad
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
check_cm_03() {
|
|
841
|
+
local n
|
|
842
|
+
n=$(cd "$ROOT" && git status --porcelain 2>/dev/null | wc -l | tr -d '[:space:]')
|
|
843
|
+
printf '%s dirty entr(y|ies), max_dirty %s\n' "$n" "$MAX_DIRTY"
|
|
844
|
+
[ "$n" -le "$MAX_DIRTY" ]
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
resolve_role_models() {
|
|
848
|
+
# prints "role<TAB>profile<TAB>provider<TAB>model<TAB>effort" for each lane of a role
|
|
849
|
+
local role="$1" f="$SELF_DIR/../roles.yaml"
|
|
850
|
+
[ -f "$f" ] || f="$ROOT/.goblin/roles.yaml"
|
|
851
|
+
local profiles
|
|
852
|
+
profiles=$(g_yaml_block_scalar "$f" "role-$role" profiles)
|
|
853
|
+
[ -n "$profiles" ] || profiles=$(g_yaml_block_scalar "$f" "$role" profiles)
|
|
854
|
+
local p prov model eff
|
|
855
|
+
for p in $(printf '%s' "$profiles" | tr -d '[]' | tr ',' ' '); do
|
|
856
|
+
prov=$(g_models_field "$MODELS_FILE" "$p" provider)
|
|
857
|
+
model=$(g_models_field "$MODELS_FILE" "$p" model)
|
|
858
|
+
eff=$(g_models_field "$MODELS_FILE" "$p" effort)
|
|
859
|
+
printf '%s\t%s\t%s\t%s\t%s\n' "$role" "$p" "${prov:-?}" "${model:-?}" "${eff:-?}"
|
|
860
|
+
done
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
check_md_02() {
|
|
864
|
+
local code_line panel_line judge_line code_fam panel_fam judge_fam
|
|
865
|
+
if [ ! -f "$MODELS_FILE" ]; then
|
|
866
|
+
printf 'no model mapping configured (%s absent) - advisory\n' "$MODELS_FILE"
|
|
867
|
+
return 2
|
|
868
|
+
fi
|
|
869
|
+
code_line=$(resolve_role_models code | head -n 1)
|
|
870
|
+
panel_line=$(resolve_role_models review-panel | head -n 1)
|
|
871
|
+
judge_line=$(resolve_role_models judge | head -n 1)
|
|
872
|
+
code_fam=$(printf '%s' "$code_line" | cut -f4)
|
|
873
|
+
panel_fam=$(printf '%s' "$panel_line" | cut -f4)
|
|
874
|
+
judge_fam=$(printf '%s' "$judge_line" | cut -f4)
|
|
875
|
+
if [ -z "$code_line" ] || [ -z "$panel_line" ]; then
|
|
876
|
+
printf 'roles.yaml did not resolve both lanes - advisory\n'
|
|
877
|
+
return 2
|
|
878
|
+
fi
|
|
879
|
+
if [ "$code_fam" = "$panel_fam" ]; then
|
|
880
|
+
printf 'code lane and review lane both resolve to %s (same family) - goblin-stack cannot change this\n' "$code_fam"
|
|
881
|
+
else
|
|
882
|
+
printf 'code lane %s, review lane %s - different family\n' "$code_fam" "$panel_fam"
|
|
883
|
+
fi
|
|
884
|
+
# W5-6: the JUDGE lane's family is compared too. JG-02 proves the declared profile NAMES are
|
|
885
|
+
# disjoint and nothing more, so a `judge:` profile mapped to the author's own model passed it:
|
|
886
|
+
# the judge was distinct by name only, which is exactly what the judge role's claim cannot
|
|
887
|
+
# tolerate. The comparison is the same one the panel gets, and the row stays ADVISORY (`return
|
|
888
|
+
# 2`): goblin-stack cannot choose the fleet's models, it can only make the state loud. Measured
|
|
889
|
+
# on this box, every profile the mapping names resolves to ONE model - recorded, not faked, in
|
|
890
|
+
# docs/LIMITS.md #38.
|
|
891
|
+
if [ -z "$judge_line" ]; then
|
|
892
|
+
printf 'the judge lane does not resolve - add a `judge:` entry beside the code lane in %s\n' "$MODELS_FILE"
|
|
893
|
+
return 2
|
|
894
|
+
fi
|
|
895
|
+
case "$judge_fam" in
|
|
896
|
+
''|'?'|unknown)
|
|
897
|
+
printf 'the judge lane resolves to no model in %s - add a `judge:` entry beside the code lane\n' "$MODELS_FILE"
|
|
898
|
+
return 2 ;;
|
|
899
|
+
esac
|
|
900
|
+
if [ "$judge_fam" = "$code_fam" ]; then
|
|
901
|
+
printf 'the judge lane resolves to %s, the SAME family as the code lane - JG-02 proves only that the declared profile names differ\n' "$judge_fam"
|
|
902
|
+
else
|
|
903
|
+
printf 'judge lane %s, code lane %s - different family\n' "$judge_fam" "$code_fam"
|
|
904
|
+
fi
|
|
905
|
+
return 2
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
check_pg_01() {
|
|
909
|
+
local f head n=0 bad=0
|
|
910
|
+
for f in "$ROOT"/reviews/*.md; do
|
|
911
|
+
[ -e "$f" ] || continue
|
|
912
|
+
n=$((n + 1))
|
|
913
|
+
head=$(sed -n 's/^head:[[:space:]]*//p' "$f" | head -n 1)
|
|
914
|
+
if [ -z "$head" ]; then printf '%s: no head: line\n' "$(basename "$f")"; bad=1; continue; fi
|
|
915
|
+
if ! ( cd "$ROOT" && git rev-list --all 2>/dev/null | grep -q "^$head" ); then
|
|
916
|
+
printf '%s: head %s is not in git rev-list --all\n' "$(basename "$f")" "$head"
|
|
917
|
+
bad=1
|
|
918
|
+
fi
|
|
919
|
+
done
|
|
920
|
+
printf '%d review note(s)\n' "$n"
|
|
921
|
+
[ "$bad" -eq 0 ]
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
check_pg_02() {
|
|
925
|
+
local f stakes lanes n=0 bad=0
|
|
926
|
+
for f in "$ROOT"/reviews/*.md; do
|
|
927
|
+
[ -e "$f" ] || continue
|
|
928
|
+
n=$((n + 1))
|
|
929
|
+
stakes=$(sed -n 's/^stakes:[[:space:]]*//p' "$f" | head -n 1)
|
|
930
|
+
case "$stakes" in
|
|
931
|
+
S[0-4]) ;;
|
|
932
|
+
*) printf '%s: stakes must be S0..S4, got "%s"\n' "$(basename "$f")" "$stakes"; bad=1; continue ;;
|
|
933
|
+
esac
|
|
934
|
+
case "$stakes" in
|
|
935
|
+
S2|S3|S4)
|
|
936
|
+
if ! awk '/^checks-run:/{c=1;next} c&&/^[a-z-]+:/{c=0} c&&NF{found=1} END{exit !found}' "$f"; then
|
|
937
|
+
printf '%s: stakes %s requires a non-empty checks-run: section\n' "$(basename "$f")" "$stakes"
|
|
938
|
+
bad=1
|
|
939
|
+
fi ;;
|
|
940
|
+
esac
|
|
941
|
+
case "$stakes" in
|
|
942
|
+
S3|S4)
|
|
943
|
+
lanes=$(sed -n 's/^lanes:[[:space:]]*//p' "$f" | head -n 1)
|
|
944
|
+
if [ -z "$lanes" ] || [ "$lanes" = "1" ]; then
|
|
945
|
+
printf '%s: stakes %s requires a second lane (lanes: 2 or more)\n' "$(basename "$f")" "$stakes"
|
|
946
|
+
bad=1
|
|
947
|
+
fi ;;
|
|
948
|
+
esac
|
|
949
|
+
done
|
|
950
|
+
printf '%d review note(s) classified\n' "$n"
|
|
951
|
+
[ "$bad" -eq 0 ]
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
check_pg_03() {
|
|
955
|
+
local f base head rec now n=0 bad=0
|
|
956
|
+
for f in "$ROOT"/reviews/*.md; do
|
|
957
|
+
[ -e "$f" ] || continue
|
|
958
|
+
n=$((n + 1))
|
|
959
|
+
base=$(sed -n 's/^base:[[:space:]]*//p' "$f" | head -n 1)
|
|
960
|
+
head=$(sed -n 's/^head:[[:space:]]*//p' "$f" | head -n 1)
|
|
961
|
+
rec=$(sed -n 's/^patch-id:[[:space:]]*//p' "$f" | head -n 1)
|
|
962
|
+
if [ -z "$base" ] || [ -z "$head" ] || [ -z "$rec" ]; then
|
|
963
|
+
printf '%s: needs base:, head: and patch-id:\n' "$(basename "$f")"; bad=1; continue
|
|
964
|
+
fi
|
|
965
|
+
now=$( cd "$ROOT" && git diff "$base".."$head" 2>/dev/null | git patch-id --stable 2>/dev/null | awk '{print $1}' )
|
|
966
|
+
if [ "$now" != "$rec" ]; then
|
|
967
|
+
printf '%s: recorded patch-id %s, now %s\n' "$(basename "$f")" "$rec" "${now:-<none>}"
|
|
968
|
+
bad=1
|
|
969
|
+
fi
|
|
970
|
+
done
|
|
971
|
+
printf '%d review note(s) patch-id checked\n' "$n"
|
|
972
|
+
[ "$bad" -eq 0 ]
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
check_pg_05() {
|
|
976
|
+
# PG-05 - "No required check that self-skips". The old body counted only "every step is
|
|
977
|
+
# guarded", so the REAL shape passed: in the estate's one existing workflow a deliberately
|
|
978
|
+
# UNGUARDED credential step decides whether the guarded compile step runs, so
|
|
979
|
+
# `guarded < steps` and the
|
|
980
|
+
# row reported PASS on the workflow it exists to catch (G8 section 3, re-measured V3-4). G6
|
|
981
|
+
# section A.6 named the mechanisable tell - a job whose GATE STEP is conditional can reach its
|
|
982
|
+
# end without running the gate while the required check still reports Success - so the row now
|
|
983
|
+
# carries three clauses per job:
|
|
984
|
+
# (a) the job declares at least one run:/uses: step (otherwise the check runs nothing);
|
|
985
|
+
# (b) the JOB carries no `if:` (otherwise the whole required check self-skips);
|
|
986
|
+
# (c) no STEP carries an `if:` (otherwise that step - possibly the gate step - self-skips,
|
|
987
|
+
# and GitHub reports a skipped job as Success: docs S1/S2, RISKS.md K7).
|
|
988
|
+
# It is a TEXT reading, deliberately strict, and says so in its own why-cell: a `#` inside a
|
|
989
|
+
# quoted string is read as a comment, a flow-style `jobs:` mapping is refused rather than read
|
|
990
|
+
# as clean, and a condition that is genuinely needed belongs in the command or in a second
|
|
991
|
+
# job that is not the required check.
|
|
992
|
+
local f n=0 bad=0
|
|
993
|
+
for f in "$ROOT"/.github/workflows/*.yml "$ROOT"/.github/workflows/*.yaml; do
|
|
994
|
+
[ -e "$f" ] || continue
|
|
995
|
+
n=$((n + 1))
|
|
996
|
+
awk -v FILE="$(basename "$f")" '
|
|
997
|
+
function closejob() {
|
|
998
|
+
if (jobname == "") return
|
|
999
|
+
if (steps == 0) { printf "%s: job \"%s\" declares no run:/uses: step - the required check runs nothing and still reports Success\n", FILE, jobname; bad = 1 }
|
|
1000
|
+
if (jobif > 0) { printf "%s: job \"%s\" carries a job-level if: (%d) - the whole required check self-skips and reports Success\n", FILE, jobname, jobif; bad = 1 }
|
|
1001
|
+
if (stepif > 0) { printf "%s: job \"%s\" has %d conditional step(s) - a skipped step reports Success, and a skipped gate step green-lights a commit whose gate never ran\n", FILE, jobname, stepif; bad = 1 }
|
|
1002
|
+
jobname = ""
|
|
1003
|
+
}
|
|
1004
|
+
{
|
|
1005
|
+
line = $0
|
|
1006
|
+
sub(/[ \t]+#.*$/, "", line) # blank a trailing comment
|
|
1007
|
+
if (line ~ /^[ \t]*#/) next
|
|
1008
|
+
if (line ~ /^[ \t]*$/) next
|
|
1009
|
+
if (!injobs) {
|
|
1010
|
+
if (line ~ /^jobs:[ \t]*$/) { injobs = 1; jind = -1 }
|
|
1011
|
+
else if (line ~ /^jobs:[ \t]*[^ \t]/) { printf "%s: the jobs: block is a flow mapping - it cannot be read here, so it is refused rather than read as clean\n", FILE; bad = 1; injobs = 1; jind = -1 }
|
|
1012
|
+
next
|
|
1013
|
+
}
|
|
1014
|
+
lead = match(line, /[^ \t]/) - 1
|
|
1015
|
+
if (jind >= 0 && lead == jind && line !~ /^[ \t]*-/) {
|
|
1016
|
+
closejob()
|
|
1017
|
+
jobname = line; sub(/^[ \t]*/, "", jobname); sub(/[ \t]*:.*/, "", jobname)
|
|
1018
|
+
steps = 0; stepregion = 0; jobif = 0; stepif = 0
|
|
1019
|
+
next
|
|
1020
|
+
}
|
|
1021
|
+
if (jind < 0) {
|
|
1022
|
+
jind = lead
|
|
1023
|
+
jobname = line; sub(/^[ \t]*/, "", jobname); sub(/[ \t]*:.*/, "", jobname)
|
|
1024
|
+
steps = 0; stepregion = 0; jobif = 0; stepif = 0
|
|
1025
|
+
next
|
|
1026
|
+
}
|
|
1027
|
+
if (lead <= jind) next
|
|
1028
|
+
if (line ~ /^[ \t]*-/) stepregion = 1
|
|
1029
|
+
if (line ~ /(^|[ \t-])(run|uses):/) { steps++; next }
|
|
1030
|
+
if (line ~ /^[ \t]*-?[ \t]*if:/) { if (stepregion) stepif++; else jobif++ }
|
|
1031
|
+
}
|
|
1032
|
+
END {
|
|
1033
|
+
closejob()
|
|
1034
|
+
if (!injobs) { printf "%s: no jobs: block - the workflow declares no check at all\n", FILE; bad = 1 }
|
|
1035
|
+
exit bad
|
|
1036
|
+
}
|
|
1037
|
+
' "$f" || bad=1
|
|
1038
|
+
done
|
|
1039
|
+
if [ "$n" -eq 0 ]; then
|
|
1040
|
+
printf '0 workflow file(s) - no required check here to skip (vacuous by design: nothing declares one)\n'
|
|
1041
|
+
else
|
|
1042
|
+
printf '%d workflow file(s) checked for a self-skipping required check\n' "$n"
|
|
1043
|
+
fi
|
|
1044
|
+
[ "$bad" -eq 0 ]
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
check_pg_06() {
|
|
1048
|
+
# PG-06 (G6 section A.6, row 2) - "The gate CI runs is the gate the project declares." The
|
|
1049
|
+
# failure mode is R5 section 1.1 measured: review and CI and goblin-verify reporting different
|
|
1050
|
+
# truths about one SHA, so a green required check says nothing about the gate the project
|
|
1051
|
+
# actually declares. The declared set comes from g_yaml_gates - GT-01's own reader, reused
|
|
1052
|
+
# rather than re-derived, so a gate cannot vanish from the comparison in silence (G8-3).
|
|
1053
|
+
# A workflow satisfies the row when the whole declared set is RUN: either the verifier with no
|
|
1054
|
+
# `--only` (a full run executes every declared gate through GT-02), or each declared gate
|
|
1055
|
+
# command appearing verbatim. Comments are blanked first, so a commented-out verifier call is
|
|
1056
|
+
# not a run, and key lines other than `run:` are dropped, so a command sitting in a `name:`
|
|
1057
|
+
# cannot satisfy it. Partial by construction: it proves the gate is INVOKED in a file under
|
|
1058
|
+
# `.github/workflows/`, never that GitHub marks that job required, and never that the invoker
|
|
1059
|
+
# is the job the forge waits on.
|
|
1060
|
+
local f n=0 bad=0 gname gcmd ngates=0 gates="" runtext miss="" nrun nc
|
|
1061
|
+
while IFS=$'\t' read -r gname gcmd; do
|
|
1062
|
+
[ -n "$gname" ] || continue
|
|
1063
|
+
ngates=$((ngates + 1))
|
|
1064
|
+
[ -n "$gcmd" ] && gates="$gates$gcmd"$'\n'
|
|
1065
|
+
done < <(g_yaml_gates "$CONFIG")
|
|
1066
|
+
for f in "$ROOT"/.github/workflows/*.yml "$ROOT"/.github/workflows/*.yaml; do
|
|
1067
|
+
[ -e "$f" ] || continue
|
|
1068
|
+
n=$((n + 1))
|
|
1069
|
+
runtext=$(awk '
|
|
1070
|
+
{
|
|
1071
|
+
line = $0
|
|
1072
|
+
sub(/[ \t]+#.*$/, "", line)
|
|
1073
|
+
if (line ~ /^[ \t]*#/) next
|
|
1074
|
+
if (line ~ /^[ \t]*$/) next
|
|
1075
|
+
if (line ~ /^[ \t]*-?[ \t]*run:/) { print substr(line, index(line, "run:") + 4); next }
|
|
1076
|
+
if (line ~ /^[ \t]*-?[ \t]*[A-Za-z_][A-Za-z0-9_.-]*:/) next
|
|
1077
|
+
print line
|
|
1078
|
+
}
|
|
1079
|
+
' "$f")
|
|
1080
|
+
if printf '%s\n' "$runtext" | grep -q 'goblin-verify' \
|
|
1081
|
+
&& ! printf '%s\n' "$runtext" | grep -q 'goblin-verify[^&|;]*--only'; then
|
|
1082
|
+
printf '%s: runs the verifier, which executes every declared gate\n' "$(basename "$f")"
|
|
1083
|
+
continue
|
|
1084
|
+
fi
|
|
1085
|
+
# The verbatim path: compare on whitespace-normalised text, so a YAML line wrap or a dropped
|
|
1086
|
+
# `2>/dev/null` does not read as "not the declared gate".
|
|
1087
|
+
nrun=$(printf '%s\n' "$runtext" | tr '\n' ' ' | sed 's#2>/dev/null##g' | tr -s ' ')
|
|
1088
|
+
miss=""
|
|
1089
|
+
while IFS= read -r gcmd; do
|
|
1090
|
+
[ -n "$gcmd" ] || continue
|
|
1091
|
+
nc=$(printf '%s' "$gcmd" | sed 's#2>/dev/null##g' | tr -s ' ')
|
|
1092
|
+
printf '%s' "$nrun" | grep -qF -- "$nc" || miss="$miss [$gcmd]"
|
|
1093
|
+
done <<EOF
|
|
1094
|
+
$gates
|
|
1095
|
+
EOF
|
|
1096
|
+
if [ -n "$miss" ]; then
|
|
1097
|
+
printf '%s: runs no gate the project declares - declared but not run:%s\n' "$(basename "$f")" "$miss"
|
|
1098
|
+
bad=1
|
|
1099
|
+
else
|
|
1100
|
+
printf '%s: runs all %d declared gate(s) verbatim\n' "$(basename "$f")" "$ngates"
|
|
1101
|
+
fi
|
|
1102
|
+
done
|
|
1103
|
+
if [ "$n" -eq 0 ]; then
|
|
1104
|
+
printf 'no workflow file to check - CI runs no gate here\n'
|
|
1105
|
+
return 3
|
|
1106
|
+
fi
|
|
1107
|
+
printf '%d workflow file(s) compared with the %d declared gate(s)\n' "$n" "$ngates"
|
|
1108
|
+
[ "$bad" -eq 0 ]
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
hash_runtime_data() {
|
|
1112
|
+
local p h out=""
|
|
1113
|
+
while read -r p; do
|
|
1114
|
+
[ -n "$p" ] || continue
|
|
1115
|
+
h=$(g_sha256_file "$ROOT/$p")
|
|
1116
|
+
out="$out$p=$h "
|
|
1117
|
+
done < <(g_yaml_list "$CONFIG" runtime_data)
|
|
1118
|
+
printf '%s' "${out% }"
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
check_ds_01() {
|
|
1122
|
+
local before after bad=0 p
|
|
1123
|
+
# Clause the row's own rule states and the old body dropped: every declared path must be
|
|
1124
|
+
# git-ignored, or it is fixture, not runtime data (D12).
|
|
1125
|
+
while read -r p; do
|
|
1126
|
+
[ -n "$p" ] || continue
|
|
1127
|
+
( cd "$ROOT" && git check-ignore -q -- "$p" ) \
|
|
1128
|
+
|| { printf 'runtime_data path is not git-ignored: %s\n' "$p"; bad=1; }
|
|
1129
|
+
done < <(g_yaml_list "$CONFIG" runtime_data)
|
|
1130
|
+
before=$(hash_runtime_data)
|
|
1131
|
+
run_gates no >/dev/null 2>&1
|
|
1132
|
+
after=$(hash_runtime_data)
|
|
1133
|
+
printf 'before=%s\nafter=%s\n' "$before" "$after" > "$ROOT/.goblin/.ds-report"
|
|
1134
|
+
if [ "$before" != "$after" ]; then
|
|
1135
|
+
printf 'a gate run wrote runtime data: %s -> %s\n' "$before" "$after"
|
|
1136
|
+
return 1
|
|
1137
|
+
fi
|
|
1138
|
+
# Declaring nothing is not a pass: it is an unexercised rule, and it is reported as one.
|
|
1139
|
+
[ -n "$before" ] || { printf 'no runtime_data declared\n'; return 2; }
|
|
1140
|
+
[ "$bad" -eq 0 ] || return 1
|
|
1141
|
+
printf 'unchanged: %s\n' "$before"
|
|
1142
|
+
return 0
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
check_ds_02() {
|
|
1146
|
+
[ -f "$ROOT/.goblin/.ds-report" ] || { printf 'no DS-01 report in this run\n'; return 1; }
|
|
1147
|
+
local b a
|
|
1148
|
+
b=$(sed -n 's/^before=//p' "$ROOT/.goblin/.ds-report")
|
|
1149
|
+
a=$(sed -n 's/^after=//p' "$ROOT/.goblin/.ds-report")
|
|
1150
|
+
[ "$b" = "$a" ] || { printf 'before/after differ\n'; return 1; }
|
|
1151
|
+
[ -f "$ROOT/.goblin/last-gate-line" ] || { printf 'no gate line to annotate\n'; return 0; }
|
|
1152
|
+
local line
|
|
1153
|
+
line=$(tr -d '\n' < "$ROOT/.goblin/last-gate-line")
|
|
1154
|
+
printf '%s · runtime_data %s\n' "$line" "${b:-<none declared>}" > "$ROOT/.goblin/last-gate-line"
|
|
1155
|
+
printf 'snapshot %s\n' "${b:-<none declared>}"
|
|
1156
|
+
return 0
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
check_au_02() {
|
|
1160
|
+
# The dedup key must be a function of the report's CONTENT only. Recompute it from the
|
|
1161
|
+
# report's own repo+symptom and require the recorded value to equal it: a key derived from a
|
|
1162
|
+
# date, a run id or a counter dedups nothing, and this is the row that says so.
|
|
1163
|
+
local f n=0 bad=0 repo sym key want norm slug
|
|
1164
|
+
for f in "$ROOT"/reports/*/report.yaml; do
|
|
1165
|
+
[ -e "$f" ] || continue
|
|
1166
|
+
n=$((n + 1))
|
|
1167
|
+
slug=$(basename "$(dirname "$f")")
|
|
1168
|
+
repo=$(sed -n 's/^repo:[[:space:]]*//p' "$f" | head -n 1)
|
|
1169
|
+
sym=$(sed -n 's/^symptom:[[:space:]]*//p' "$f" | head -n 1)
|
|
1170
|
+
key=$(sed -n 's/^dedup_key:[[:space:]]*//p' "$f" | head -n 1)
|
|
1171
|
+
norm=$(printf '%s' "$sym" | tr 'A-Z' 'a-z' | tr -s '[:space:]' ' ' | sed 's/^ //; s/ $//')
|
|
1172
|
+
want="bug:${repo}:$(g_sha256_text "$norm" | cut -c1-12)"
|
|
1173
|
+
case "$key" in
|
|
1174
|
+
'') printf '%s: no dedup_key recorded\n' "$slug"; bad=1; continue ;;
|
|
1175
|
+
esac
|
|
1176
|
+
if [ "$key" != "$want" ]; then
|
|
1177
|
+
printf '%s: recorded key %s, recomputed %s\n' "$slug" "$key" "$want"; bad=1
|
|
1178
|
+
fi
|
|
1179
|
+
if printf '%s' "$key" | grep -qE '[0-9]{4}-[0-9]{2}-[0-9]{2}'; then
|
|
1180
|
+
printf '%s: the key carries a date: %s\n' "$slug" "$key"; bad=1
|
|
1181
|
+
fi
|
|
1182
|
+
done
|
|
1183
|
+
[ "$n" -gt 0 ] || { printf 'no reports/*/report.yaml - nothing to dedup\n'; return 3; }
|
|
1184
|
+
printf '%d report(s): the key is content-only\n' "$n"
|
|
1185
|
+
[ "$bad" -eq 0 ]
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
check_au_03() {
|
|
1189
|
+
# A reporter's write surface is reports/** plus cards. Two falsifiable clauses: the tree is
|
|
1190
|
+
# clean, and - when HEAD is a reporter commit - no path under the declared harness dir is in
|
|
1191
|
+
# it. The harness clause only applies to a commit that touches reports/, so a normal round's
|
|
1192
|
+
# commit is not mistaken for a reporter's.
|
|
1193
|
+
local dirty bad=0 touched=""
|
|
1194
|
+
[ -d "$ROOT/reports" ] || { printf 'no reports/ - no reporter run in this repo to audit\n'; return 3; }
|
|
1195
|
+
dirty=$(cd "$ROOT" && git status --porcelain 2>/dev/null | wc -l | tr -d '[:space:]')
|
|
1196
|
+
printf '%s dirty entr(y|ies), max_dirty %s\n' "$dirty" "$MAX_DIRTY"
|
|
1197
|
+
if ( cd "$ROOT" && git rev-parse --verify --quiet HEAD~1 >/dev/null 2>&1 ); then
|
|
1198
|
+
if ( cd "$ROOT" && git diff --name-only HEAD~1..HEAD 2>/dev/null | grep -q '^reports/' ); then
|
|
1199
|
+
touched=$( cd "$ROOT" && git diff --name-only HEAD~1..HEAD 2>/dev/null \
|
|
1200
|
+
| grep "^${GOBLIN_HARNESS_REL}/" || true )
|
|
1201
|
+
else
|
|
1202
|
+
printf 'HEAD is not a reporter commit - the harness clause does not apply\n'
|
|
1203
|
+
fi
|
|
1204
|
+
fi
|
|
1205
|
+
if [ -n "$touched" ]; then
|
|
1206
|
+
printf 'the reporter commit touched the harness: %s\n' "$(printf '%s' "$touched" | tr '\n' ' ')"
|
|
1207
|
+
bad=1
|
|
1208
|
+
fi
|
|
1209
|
+
[ "$dirty" -le "$MAX_DIRTY" ] || { printf 'the reporter left the tree dirty\n'; bad=1; }
|
|
1210
|
+
[ "$bad" -eq 0 ]
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
check_sc_02() {
|
|
1214
|
+
# Clause 1 is a file rule; clause 2 is BEHAVIOURAL - it asks git's own matcher, so an ignore
|
|
1215
|
+
# rule that looks right but does not match still fails. `git check-ignore -q` accepts exactly
|
|
1216
|
+
# one pathname, so the body loops.
|
|
1217
|
+
local miss="" p
|
|
1218
|
+
if [ ! -f "$ROOT/.gitignore" ]; then
|
|
1219
|
+
if [ -f "$ROOT/package.json" ]; then
|
|
1220
|
+
printf 'no .gitignore in a repo with a package.json\n'; return 1
|
|
1221
|
+
fi
|
|
1222
|
+
printf 'no .gitignore and no package.json - there is no dependency surface here to protect\n'
|
|
1223
|
+
return 3
|
|
1224
|
+
fi
|
|
1225
|
+
grep -qE '^[[:space:]]*\.env(\*|/)?[[:space:]]*$' "$ROOT/.gitignore" \
|
|
1226
|
+
|| miss="no bare .env rule"
|
|
1227
|
+
if [ -d "$ROOT/.git" ]; then
|
|
1228
|
+
for p in .env .env.local .env.production; do
|
|
1229
|
+
( cd "$ROOT" && git check-ignore -q "$p" ) || miss="$miss not-ignored:$p"
|
|
1230
|
+
done
|
|
1231
|
+
else
|
|
1232
|
+
miss="$miss no-git-repository"
|
|
1233
|
+
fi
|
|
1234
|
+
if [ -n "$miss" ]; then printf 'the secret family is not ignored:%s\n' "$miss"; return 1; fi
|
|
1235
|
+
printf 'the whole secret family is ignored (.env, .env.local, .env.production proved by git)\n'
|
|
1236
|
+
return 0
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
check_sc_03() {
|
|
1240
|
+
# Clause 1: a client-visible NAME that is secret-shaped. Clause 2: a known secret PREFIX in
|
|
1241
|
+
# real build output. The second clause is reported, never silently skipped: a repo with no
|
|
1242
|
+
# build output is told so on the line.
|
|
1243
|
+
local hits hits_all hits_n vs_keys vs_files out n=0 bad=0
|
|
1244
|
+
hits_all=$(sc_sources 'NEXT_PUBLIC_[A-Z0-9_]*(SECRET|TOKEN|KEY|PASSWORD|PASSWD|PRIVATE)')
|
|
1245
|
+
# W5-11: this line printed a CHARACTER count as a hit count - `${#hits}` on a multiline
|
|
1246
|
+
# string - so ONE matching line of 70 characters was reported as "70 client-visible
|
|
1247
|
+
# secret-shaped name(s)". The number is the count of matching LINES (capped at five lines of
|
|
1248
|
+
# evidence), which is what the word "name(s)" claims.
|
|
1249
|
+
hits_n=0
|
|
1250
|
+
[ -n "$hits_all" ] && hits_n=$(printf '%s\n' "$hits_all" | grep -c .)
|
|
1251
|
+
hits=$(printf '%s' "$hits_all" | head -n 5)
|
|
1252
|
+
if [ -n "$hits" ]; then
|
|
1253
|
+
printf 'client-visible secret-shaped name(s): %d\n%s\n' "$hits_n" "$hits"; bad=1
|
|
1254
|
+
fi
|
|
1255
|
+
out=""
|
|
1256
|
+
for d in $SEC_BUILD_OUTPUT; do
|
|
1257
|
+
[ -d "$ROOT/$d" ] || continue
|
|
1258
|
+
out="$out $(find "$ROOT/$d" -type f 2>/dev/null | head -n 200)"
|
|
1259
|
+
done
|
|
1260
|
+
for f in $out; do
|
|
1261
|
+
n=$((n + 1))
|
|
1262
|
+
grep -qE '(sk-[A-Za-z0-9]{20,}|ghp_[A-Za-z0-9]{20,}|AKIA[A-Z0-9]{16}|eyJ[A-Za-z0-9_-]{20,}\.)' "$f" 2>/dev/null \
|
|
1263
|
+
&& { printf 'a secret-shaped literal is in build output: %s\n' "${f#"$ROOT"/}"; bad=1; }
|
|
1264
|
+
done
|
|
1265
|
+
if [ "$n" -eq 0 ]; then
|
|
1266
|
+
printf '%d client-visible secret-shaped name(s); no build output to scan for clause 2\n' "$hits_n"
|
|
1267
|
+
else
|
|
1268
|
+
printf '%d client-visible secret-shaped name(s); %d build-output file(s) scanned for secret literals\n' "$hits_n" "$n"
|
|
1269
|
+
fi
|
|
1270
|
+
[ "$bad" -eq 0 ]
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
check_sc_04() {
|
|
1274
|
+
# A JS-written cookie needs `secure` + `samesite` in the SAME statement; a server-side set
|
|
1275
|
+
# needs `httpOnly` + `sameSite`. Same-statement only: a write spread over three lines is not
|
|
1276
|
+
# seen, and the row says so rather than implying it read the statement.
|
|
1277
|
+
local js srv bad=0 n=0 line
|
|
1278
|
+
js=$(sc_sources 'document\.cookie[[:space:]]*=')
|
|
1279
|
+
srv=$(sc_sources '(cookies\(\)\.set\(|res\.cookie\(|NextResponse\.cookie\()')
|
|
1280
|
+
if [ -n "$js" ]; then
|
|
1281
|
+
while IFS= read -r line; do
|
|
1282
|
+
n=$((n + 1))
|
|
1283
|
+
printf '%s' "$line" | grep -qiE 'secure' || { printf 'a JS cookie write has no secure flag: %s\n' "$(printf '%s' "$line" | cut -d: -f1-2)"; bad=1; }
|
|
1284
|
+
printf '%s' "$line" | grep -qiE 'samesite' || { printf 'a JS cookie write has no samesite flag: %s\n' "$(printf '%s' "$line" | cut -d: -f1-2)"; bad=1; }
|
|
1285
|
+
done <<EOF
|
|
1286
|
+
$js
|
|
1287
|
+
EOF
|
|
1288
|
+
fi
|
|
1289
|
+
if [ -n "$srv" ]; then
|
|
1290
|
+
while IFS= read -r line; do
|
|
1291
|
+
n=$((n + 1))
|
|
1292
|
+
printf '%s' "$line" | grep -qiE 'httponly' || { printf 'a server cookie set has no httpOnly: %s\n' "$(printf '%s' "$line" | cut -d: -f1-2)"; bad=1; }
|
|
1293
|
+
printf '%s' "$line" | grep -qiE 'samesite' || { printf 'a server cookie set has no sameSite: %s\n' "$(printf '%s' "$line" | cut -d: -f1-2)"; bad=1; }
|
|
1294
|
+
done <<EOF
|
|
1295
|
+
$srv
|
|
1296
|
+
EOF
|
|
1297
|
+
fi
|
|
1298
|
+
printf '%d cookie write(s) checked; a JS-written cookie is readable by any script - reported, not failed\n' "$n"
|
|
1299
|
+
[ "$bad" -eq 0 ]
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
check_sc_05() {
|
|
1303
|
+
# Every write handler (POST|PUT|PATCH|DELETE) in the declared route dirs must CALL a validator,
|
|
1304
|
+
# or be named in .goblin/boundary-waivers. Partial by design: it proves a validator is called,
|
|
1305
|
+
# not that the schema is right.
|
|
1306
|
+
local d f rel n=0 bad=0 w=0
|
|
1307
|
+
for d in $SEC_WRITE_ROUTES; do
|
|
1308
|
+
[ -d "$ROOT/$d" ] || continue
|
|
1309
|
+
while IFS= read -r f; do
|
|
1310
|
+
[ -n "$f" ] || continue
|
|
1311
|
+
grep -qE 'export[[:space:]]+(async[[:space:]]+)?(function[[:space:]]+)?(const[[:space:]]+)?(POST|PUT|PATCH|DELETE)\b' "$f" || continue
|
|
1312
|
+
n=$((n + 1))
|
|
1313
|
+
rel="${f#"$ROOT"/}"
|
|
1314
|
+
if grep -qE 'safeParse|zod|valibot|yup|ajv|superstruct|validate\(' "$f"; then continue; fi
|
|
1315
|
+
if [ -f "$ROOT/.goblin/boundary-waivers" ] && grep -qE "^[[:space:]]*${rel}([[:space:]]|$)" "$ROOT/.goblin/boundary-waivers"; then
|
|
1316
|
+
w=$((w + 1)); continue
|
|
1317
|
+
fi
|
|
1318
|
+
printf 'a write handler validates nothing and is not waived: %s\n' "$rel"; bad=1
|
|
1319
|
+
done <<EOF
|
|
1320
|
+
$(find "$ROOT/$d" -type f \( -name 'route.ts' -o -name 'route.js' -o -name 'route.tsx' \) 2>/dev/null)
|
|
1321
|
+
EOF
|
|
1322
|
+
done
|
|
1323
|
+
printf '%d write handler(s), %d waived; %d dir(s) declared\n' "$n" "$w" "${#SEC_WRITE_ROUTES}"
|
|
1324
|
+
[ "$bad" -eq 0 ]
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
check_sc_06() {
|
|
1328
|
+
[ -f "$ROOT/package.json" ] || { printf 'no package.json - there is no dependency manifest to lock\n'; return 3; }
|
|
1329
|
+
local f found=""
|
|
1330
|
+
for f in package-lock.json pnpm-lock.yaml yarn.lock bun.lockb bun.lock; do
|
|
1331
|
+
[ -f "$ROOT/$f" ] && found="$f"
|
|
1332
|
+
done
|
|
1333
|
+
[ -n "$found" ] || { printf 'package.json is present with no lockfile beside it\n'; return 1; }
|
|
1334
|
+
if [ -d "$ROOT/.git" ]; then
|
|
1335
|
+
( cd "$ROOT" && git ls-files --error-unmatch "$found" >/dev/null 2>&1 ) \
|
|
1336
|
+
|| { printf '%s exists but is not tracked\n' "$found"; return 1; }
|
|
1337
|
+
printf '%s is present and tracked\n' "$found"
|
|
1338
|
+
else
|
|
1339
|
+
printf '%s is present; the tracked clause is ordered after `git init`\n' "$found"
|
|
1340
|
+
fi
|
|
1341
|
+
return 0
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
# check_in_03 — the manifest self-check, run against whichever manifest the engine
|
|
1345
|
+
# actually resolved (W1 §2.5: the row's meaning is untouched, the hardcoded per-repo
|
|
1346
|
+
# path input follows the chain §2.3). Same three clauses as the row always carried.
|
|
1347
|
+
check_in_03() {
|
|
1348
|
+
local bad=0
|
|
1349
|
+
awk -F'\t' 'NR>1 && $6=="" {print "row " $1 " has no check and no advisory label"; bad=1} END{exit bad}' "$MANIFEST" || bad=1
|
|
1350
|
+
awk -F'\t' 'NR>1 && ($6=="advisory" && $4!="advisory") {print "row " $1 " is advisory-labelled but its check column claims enforcement"; bad=1} END{exit bad}' "$MANIFEST" || bad=1
|
|
1351
|
+
awk -F'\t' 'NR>1 && $4 !~ /^(script|lint|gate|advisory|test)$/ {print "row " $1 " has an enforced_by outside the closed enum: " $4; bad=1} END{exit bad}' "$MANIFEST" || bad=1
|
|
1352
|
+
[ "$bad" -eq 0 ] && { printf 'the manifest is complete: every rule carries a check or is advisory, inside the enum\n'; return 0; }
|
|
1353
|
+
return 1
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
check_sc_07() {
|
|
1357
|
+
# Offline by construction: the record is written by .goblin/bin/goblin-audit, run ONCE,
|
|
1358
|
+
# deliberately. A verify-time network call would break the offline dependency contract
|
|
1359
|
+
# (docs/RISKS.md K4). The record and the waiver table are repo-local by design (W1 §2.5);
|
|
1360
|
+
# only the SKIP remedy names the runner, and global mode names the CLI verb - the record
|
|
1361
|
+
# is the repo's, the tool that produced it is wherever the engine resolves (chain §2.3).
|
|
1362
|
+
[ -n "$SEC_AUDIT_CMD" ] || { printf 'this class declares no audit command (security.audit_cmd is empty)\n'; return 3; }
|
|
1363
|
+
local rec="$ROOT/.goblin/audit.tsv" wfile="$ROOT/.goblin/audit-waiver.tsv"
|
|
1364
|
+
if [ ! -f "$rec" ]; then
|
|
1365
|
+
if [ "$ENGINE_MODE" = "global" ]; then
|
|
1366
|
+
printf 'no audit record yet. Run `goblin audit` once, deliberately, then commit\n.goblin/audit.tsv: SC-07 reads the record and never the network\n'
|
|
1367
|
+
else
|
|
1368
|
+
printf 'no audit record yet. Run .goblin/bin/goblin-audit once, deliberately, then commit\n.goblin/audit.tsv: SC-07 reads the record and never the network\n'
|
|
1369
|
+
fi
|
|
1370
|
+
return 3
|
|
1371
|
+
fi
|
|
1372
|
+
local d age lines=0 waivers=0 pkg sev id bad=0 wl wd
|
|
1373
|
+
d=$(sed -n 's/^measured[[:space:]]*//p' "$rec" | head -n 1)
|
|
1374
|
+
if [ -z "$d" ]; then printf 'the audit record carries no `measured` date\n'; return 1; fi
|
|
1375
|
+
age=$(age_days "$d") || { printf 'the recorded date does not parse on this host: %s\n' "$d"; return 1; }
|
|
1376
|
+
if [ "$age" -gt "$SEC_AUDIT_MAX_AGE" ]; then
|
|
1377
|
+
printf 'the audit record is %s days old (max %s): run goblin-audit again\n' "$age" "$SEC_AUDIT_MAX_AGE"
|
|
1378
|
+
bad=1
|
|
1379
|
+
fi
|
|
1380
|
+
while IFS=$'\t' read -r pkg sev id rest; do
|
|
1381
|
+
[ -n "$pkg" ] || continue
|
|
1382
|
+
case "$pkg" in \#*) continue ;; esac
|
|
1383
|
+
case "$sev" in high|critical) : ;; *) continue ;; esac
|
|
1384
|
+
lines=$((lines + 1))
|
|
1385
|
+
wl=""
|
|
1386
|
+
if [ -f "$wfile" ]; then
|
|
1387
|
+
wl=$(awk -F'\t' -v p="$pkg" -v i="$id" '$1==p && ($3==i || $3=="*") { print $4; exit }' "$wfile")
|
|
1388
|
+
fi
|
|
1389
|
+
if [ -z "$wl" ]; then
|
|
1390
|
+
printf 'unwaived %s advisory: %s %s\n' "$sev" "$pkg" "$id"; bad=1; continue
|
|
1391
|
+
fi
|
|
1392
|
+
waivers=$((waivers + 1))
|
|
1393
|
+
wd=$(age_days "$wl") || { printf 'the waiver for %s carries an unparseable date: %s\n' "$pkg" "$wl"; bad=1; continue; }
|
|
1394
|
+
[ "$wd" -le "$SEC_WAIVER_MAX_AGE" ] \
|
|
1395
|
+
|| { printf 'the waiver for %s is %s days old (max %s): re-decide it\n' "$pkg" "$wd" "$SEC_WAIVER_MAX_AGE"; bad=1; }
|
|
1396
|
+
done < "$rec"
|
|
1397
|
+
printf 'audit measured %s (%s days old): %s high|critical line(s), %s matched waiver(s), %s unwaived\n' \
|
|
1398
|
+
"$d" "$age" "$lines" "$waivers" "$((lines - waivers))"
|
|
1399
|
+
[ "$bad" -eq 0 ]
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
check_sc_08() {
|
|
1403
|
+
# An install hook is arbitrary code that runs on every `npm ci`. Only npm's lockfile records
|
|
1404
|
+
# it (`hasInstallScript`); pnpm/yarn do not, so the row SKIPs with that reason rather than
|
|
1405
|
+
# passing vacuously.
|
|
1406
|
+
local lock="$ROOT/package-lock.json"
|
|
1407
|
+
if [ ! -f "$lock" ]; then
|
|
1408
|
+
if [ -f "$ROOT/pnpm-lock.yaml" ] || [ -f "$ROOT/yarn.lock" ]; then
|
|
1409
|
+
printf 'a pnpm/yarn lockfile carries no hasInstallScript field - nothing to enumerate\n'; return 3
|
|
1410
|
+
fi
|
|
1411
|
+
printf 'no package-lock.json - there is no install-hook surface to enumerate\n'; return 3
|
|
1412
|
+
fi
|
|
1413
|
+
local pkgs n=0 ok=0 bad=0 p
|
|
1414
|
+
# Z2-2: this reader is line-anchored, so a MINIFIED (one-line) lockfile - valid JSON, and what a
|
|
1415
|
+
# tool that writes without pretty-printing leaves behind - matched NOTHING and the row printed
|
|
1416
|
+
# `0 install hook(s), 0 allowlisted` as a PASS, exit 0. That is a vacuous PASS: the exact defect
|
|
1417
|
+
# family (D6/PR-03) this harness exists to prevent, and the one shape the pnpm/yarn branch above
|
|
1418
|
+
# already reports honestly. The text is normalised into the pretty shape first - break after
|
|
1419
|
+
# every `{`, `}` and `,` - which is a TEXT transform, not a JSON parse: no npm, no jq, no parser
|
|
1420
|
+
# (docs/CONTRACTS.md). The anchor accepts `": {` (what npm writes) and `":{` (minified), and the
|
|
1421
|
+
# per-line state machine still attributes `hasInstallScript` to the key line it followed.
|
|
1422
|
+
pkgs=$(awk '
|
|
1423
|
+
{
|
|
1424
|
+
gsub(/\{/, "{\n"); gsub(/\}/, "}\n"); gsub(/,/, ",\n")
|
|
1425
|
+
n = split($0, L, "\n")
|
|
1426
|
+
for (i = 1; i <= n; i++) {
|
|
1427
|
+
s = L[i]
|
|
1428
|
+
if (s ~ /^[[:space:]]*"node_modules\/[^"]+":[[:space:]]*\{/) {
|
|
1429
|
+
k = s; sub(/^[[:space:]]*"/, "", k); sub(/":[[:space:]]*\{.*/, "", k); sub(/^node_modules\//, "", k)
|
|
1430
|
+
}
|
|
1431
|
+
if (s ~ /"hasInstallScript"[[:space:]]*:[[:space:]]*true/ && k != "") print k
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
' "$lock" | sort -u)
|
|
1435
|
+
for p in $pkgs; do
|
|
1436
|
+
n=$((n + 1))
|
|
1437
|
+
if [ -f "$ROOT/.goblin/install-hooks.allowlist" ] \
|
|
1438
|
+
&& grep -qxF "$p" "$ROOT/.goblin/install-hooks.allowlist"; then
|
|
1439
|
+
ok=$((ok + 1))
|
|
1440
|
+
else
|
|
1441
|
+
printf 'an install-time script is not on the allowlist: %s\n' "$p"; bad=1
|
|
1442
|
+
fi
|
|
1443
|
+
done
|
|
1444
|
+
printf '%s install hook(s), %s allowlisted\n' "$n" "$ok"
|
|
1445
|
+
[ "$bad" -eq 0 ]
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
# ---- W1 §2.5, the SK/automation split ----------------------------------------
|
|
1449
|
+
# In global mode the procedure tier (skills, the automation producers) is served from the
|
|
1450
|
+
# engine, not emitted in the repo - emission is W4's. Hashing a global copy from a repo's
|
|
1451
|
+
# verify would make one machine's skill set answer for every repo, so the per-repo rows
|
|
1452
|
+
# SKIP with that reason instead of FAILing on the absent copies. SK-03 runs unchanged:
|
|
1453
|
+
# it reads the manifest, which resolves via the chain either way.
|
|
1454
|
+
|
|
1455
|
+
# au01_producer_count — the producers an AU-01 run can see, engine-first in global mode.
|
|
1456
|
+
# In global mode the first glob resolves under the engine dir (chain §2.3); the repo-local
|
|
1457
|
+
# globs stay. In vendored mode this is exactly today's three globs.
|
|
1458
|
+
au01_globs() {
|
|
1459
|
+
if [ "$ENGINE_MODE" = "global" ]; then
|
|
1460
|
+
printf '%s\n' "$GOBLIN_ENGINE/automations/*.sh" "$ROOT/automations/*.sh" "$ROOT/automations/*/*.sh"
|
|
1461
|
+
else
|
|
1462
|
+
printf '%s\n' "$ROOT/.goblin/automations/*.sh" "$ROOT/automations/*.sh" "$ROOT/automations/*/*.sh"
|
|
1463
|
+
fi
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
check_au_01() {
|
|
1467
|
+
# One control loop over the producer globs. The new clause (W1 §2.5, Q2's narrow form):
|
|
1468
|
+
# a repo that declares NO automations and has no producer anywhere SKIPs with the reason
|
|
1469
|
+
# - the measured M9 FAIL was a born-RED artifact of the glob, and a repo that never
|
|
1470
|
+
# adopted automations must not be failed for it. A repo that DECLARES automations (its
|
|
1471
|
+
# own, repo-local) but has no producer still FAILs: the SKIP is only for repos that
|
|
1472
|
+
# declare none.
|
|
1473
|
+
local f n=0 own=0 glob
|
|
1474
|
+
# The repo's OWN producers: anything under a root automations/ directory (the engine's
|
|
1475
|
+
# payload lives under .goblin/automations/ in vendored mode, engine_dir in global mode;
|
|
1476
|
+
# neither is a declaration by this repo).
|
|
1477
|
+
for f in "$ROOT"/automations/*.sh "$ROOT"/automations/*/*.sh; do
|
|
1478
|
+
[ -e "$f" ] || continue
|
|
1479
|
+
own=$((own + 1))
|
|
1480
|
+
done
|
|
1481
|
+
while IFS= read -r glob; do
|
|
1482
|
+
for f in $glob; do
|
|
1483
|
+
[ -e "$f" ] || continue
|
|
1484
|
+
n=$((n + 1))
|
|
1485
|
+
if grep -nE "^[[:space:]]*(curl|wget|gh[[:space:]]|npm[[:space:]]|npx[[:space:]])" "$f" >/dev/null 2>&1; then
|
|
1486
|
+
printf 'a network or forge verb opens a line of the producer:\n'
|
|
1487
|
+
grep -nE "^[[:space:]]*(curl|wget|gh[[:space:]]|npm[[:space:]]|npx[[:space:]])" "$f"
|
|
1488
|
+
return 1
|
|
1489
|
+
fi
|
|
1490
|
+
done
|
|
1491
|
+
done < <(au01_globs)
|
|
1492
|
+
# The declaration clause: a root automations/ directory that holds NO .sh producer is
|
|
1493
|
+
# a declaration whose producer is gone (the files were deleted, not never written).
|
|
1494
|
+
local declared=0
|
|
1495
|
+
for f in "$ROOT"/automations/* "$ROOT"/automations/*/; do
|
|
1496
|
+
[ -e "$f" ] || continue
|
|
1497
|
+
declared=1
|
|
1498
|
+
done
|
|
1499
|
+
if [ "$declared" -eq 1 ] && [ "$own" -eq 0 ]; then
|
|
1500
|
+
printf 'automations are declared but no producer exists - the producer is gone\n'
|
|
1501
|
+
return 1
|
|
1502
|
+
fi
|
|
1503
|
+
if [ "$n" -eq 0 ]; then
|
|
1504
|
+
printf 'no automation producer found (repo or engine)\n'
|
|
1505
|
+
return 3
|
|
1506
|
+
fi
|
|
1507
|
+
printf '%d automation producer(s) scanned, none opens with a network or forge verb\n' "$n"
|
|
1508
|
+
return 0
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
check_bn_00() {
|
|
1512
|
+
# The coherence row: the ban table and the matrix must agree in BOTH directions, every ban
|
|
1513
|
+
# must name a replacement, and the table must not be empty. IN-03's shape applied to
|
|
1514
|
+
# bans.tsv - a rule that lives only in a data file would otherwise be invisible to SK-03.
|
|
1515
|
+
local bans ban_ids row_ids id n bad=0
|
|
1516
|
+
# The ban table resolves through the engine chain (W1 §2.3): vendored first, then the
|
|
1517
|
+
# declared/global engine - the fallback slots into chain steps 2/3/4 where it used to
|
|
1518
|
+
# hardcode two of them.
|
|
1519
|
+
bans="$ROOT/.goblin/manifest/bans.tsv"
|
|
1520
|
+
[ -f "$bans" ] || bans="${GOBLIN_ENGINE:-$SELF_DIR/../..}/manifest/bans.tsv"
|
|
1521
|
+
[ -f "$bans" ] || bans="$SELF_DIR/../manifest/bans.tsv"
|
|
1522
|
+
[ -f "$bans" ] || { printf 'no ban table found (manifest/bans.tsv)\n'; return 1; }
|
|
1523
|
+
n=$(awk -F'\t' 'NR>1 && $1!="" {n++} END{print n+0}' "$bans")
|
|
1524
|
+
[ "$n" -gt 0 ] || { printf 'the ban table is empty - a ban list that enforces nothing\n'; return 1; }
|
|
1525
|
+
awk -F'\t' 'NR>1 && $1!="" && $5=="" {print "ban " $1 " names no replacement"; bad=1} END{exit bad}' "$bans" \
|
|
1526
|
+
|| bad=1
|
|
1527
|
+
ban_ids=$(awk -F'\t' 'NR>1 && $1!="" {print $1}' "$bans" | sort)
|
|
1528
|
+
row_ids=$(awk -F'\t' 'NR>1 && $1 ~ /^BN-/ && $1 != "BN-00" {print $1}' "$MANIFEST" | sort)
|
|
1529
|
+
while read -r id; do
|
|
1530
|
+
[ -n "$id" ] || continue
|
|
1531
|
+
printf '%s\n' "$ban_ids" | grep -qx "$id" || { printf 'enforcement row %s has no ban in the table\n' "$id"; bad=1; }
|
|
1532
|
+
done <<EOF
|
|
1533
|
+
$row_ids
|
|
1534
|
+
EOF
|
|
1535
|
+
while read -r id; do
|
|
1536
|
+
[ -n "$id" ] || continue
|
|
1537
|
+
printf '%s\n' "$row_ids" | grep -qx "$id" || { printf 'ban %s has no enforcement row\n' "$id"; bad=1; }
|
|
1538
|
+
done <<EOF
|
|
1539
|
+
$ban_ids
|
|
1540
|
+
EOF
|
|
1541
|
+
[ "$bad" -eq 0 ] || return 1
|
|
1542
|
+
printf '%d ban(s), each with an enforcement row and a named replacement\n' "$n"
|
|
1543
|
+
return 0
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
check_bn_run() {
|
|
1547
|
+
# One ban, through the resolved engine. rc: 0 pass | 1 fail | 3 skip | anything else fail
|
|
1548
|
+
# closed (the engine's own exit 2 = the table is missing/empty, which is not a pass).
|
|
1549
|
+
# W1 §2.5: the engine is <resolved engine>/bin/goblin-bans (chain §2.3). The hard-FAIL
|
|
1550
|
+
# stays for the vendored mode; in global mode the engine's own existence was already
|
|
1551
|
+
# checked at resolution (§1.2 item 4), so an absent ban engine is exit-2-class, but it
|
|
1552
|
+
# is still reported FAIL-closed here rather than silently skipped - the ban lane must
|
|
1553
|
+
# never read clean because a binary went missing.
|
|
1554
|
+
local id="$1" engine out rc detail
|
|
1555
|
+
engine="$ROOT/.goblin/bin/goblin-bans"
|
|
1556
|
+
if [ ! -f "$engine" ] && [ -n "$GOBLIN_ENGINE" ]; then engine="$GOBLIN_ENGINE/bin/goblin-bans"; fi
|
|
1557
|
+
[ -f "$engine" ] || { printf 'the ban engine is not installed: .goblin/bin/goblin-bans\n'; return 1; }
|
|
1558
|
+
out=$( cd "$ROOT" && bash "$engine" --only "$id" 2>&1 ); rc=$?
|
|
1559
|
+
detail=$(printf '%s\n' "$out" | grep -vE '^ [0-9]+ passed' \
|
|
1560
|
+
| sed 's/^\(PASS\|FAIL\|SKIP\|ADV\) \{1,\}[A-Z0-9-]* *//' | sed '/^$/d' \
|
|
1561
|
+
| head -n 3 | tr '\n' ' ')
|
|
1562
|
+
printf '%s\n' "${detail:-no detail}"
|
|
1563
|
+
case "$rc" in
|
|
1564
|
+
0) return 0 ;;
|
|
1565
|
+
1) return 1 ;;
|
|
1566
|
+
3) return 3 ;;
|
|
1567
|
+
*) return 1 ;;
|
|
1568
|
+
esac
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
check_bn_01() { check_bn_run BN-01; }
|
|
1572
|
+
check_bn_02() { check_bn_run BN-02; }
|
|
1573
|
+
check_bn_03() { check_bn_run BN-03; }
|
|
1574
|
+
check_bn_05() { check_bn_run BN-05; }
|
|
1575
|
+
# G6 section B.2: the Electron failure surface, as bans. Same engine, same exit contract, same
|
|
1576
|
+
# SKIP-when-unlisted behaviour - the mechanism is G5's, only the patterns are new.
|
|
1577
|
+
check_bn_06() { check_bn_run BN-06; }
|
|
1578
|
+
check_bn_07() { check_bn_run BN-07; }
|
|
1579
|
+
check_bn_08() { check_bn_run BN-08; }
|
|
1580
|
+
check_bn_09() { check_bn_run BN-09; }
|
|
1581
|
+
|
|
1582
|
+
check_pf_01() {
|
|
1583
|
+
# The perf NUMBER lives in `ratchet:` (GT-04/GT-05 unchanged). This row is the other half: the
|
|
1584
|
+
# baseline must name the commit it was measured on, so raising the ceiling cannot silently
|
|
1585
|
+
# become never measuring again. An empty metric or an empty baseline means "not measured yet"
|
|
1586
|
+
# - a skip with that reason, because a row that is RED on every fresh install teaches people
|
|
1587
|
+
# to ignore it.
|
|
1588
|
+
local rname bad=0
|
|
1589
|
+
rname=$(g_yaml_block_scalar "$CONFIG" ratchet name)
|
|
1590
|
+
[ -n "$PERF_METRIC" ] || {
|
|
1591
|
+
printf 'this class declares no perf metric (perf.metric is empty). The ratchet still carries\n%s; declare metric/cmd here the day you pick one\n' "${rname:-nothing}"
|
|
1592
|
+
return 3
|
|
1593
|
+
}
|
|
1594
|
+
[ -n "$PERF_BASELINE_COMMIT" ] || {
|
|
1595
|
+
printf 'no perf baseline recorded yet: run `%s` on a pinned commit, then write\nperf.baseline_commit, perf.baseline_value, perf.measured and the matching ratchet ceiling\n' "$PERF_CMD"
|
|
1596
|
+
return 3
|
|
1597
|
+
}
|
|
1598
|
+
[ "$PERF_METRIC" = "$rname" ] \
|
|
1599
|
+
|| { printf 'perf.metric (%s) and ratchet.name (%s) disagree - the budget and the measurement are two different numbers\n' "$PERF_METRIC" "$rname"; bad=1; }
|
|
1600
|
+
case "$PERF_BASELINE_VALUE" in
|
|
1601
|
+
''|*[!0-9]*) printf 'perf.baseline_value is not a number: %s\n' "${PERF_BASELINE_VALUE:-<empty>}"; bad=1 ;;
|
|
1602
|
+
esac
|
|
1603
|
+
case "$PERF_MEASURED" in
|
|
1604
|
+
????-??-??) : ;;
|
|
1605
|
+
*) printf 'perf.measured is not an ISO date: %s\n' "${PERF_MEASURED:-<empty>}"; bad=1 ;;
|
|
1606
|
+
esac
|
|
1607
|
+
( cd "$ROOT" && git cat-file -e "${PERF_BASELINE_COMMIT}^{commit}" 2>/dev/null ) \
|
|
1608
|
+
|| { printf 'perf.baseline_commit does not resolve: %s\n' "$PERF_BASELINE_COMMIT"; bad=1; }
|
|
1609
|
+
( cd "$ROOT" && git merge-base --is-ancestor "$PERF_BASELINE_COMMIT" HEAD 2>/dev/null ) \
|
|
1610
|
+
|| { printf 'perf.baseline_commit is not an ancestor of HEAD: %s - the number describes a tree nobody has\n' "$PERF_BASELINE_COMMIT"; bad=1; }
|
|
1611
|
+
# The cross-check G8-6b asked for: the budget and the measurement must be the SAME number.
|
|
1612
|
+
# Without it a one-line `ceiling:` raise passed while the row printed `baseline_value 0 ...
|
|
1613
|
+
# (ceiling 100000)` - the contradiction on its own passing line, seen by nothing. The ceiling
|
|
1614
|
+
# is anchored on the recorded baseline; raising it is what "I never measured again" looks like.
|
|
1615
|
+
local ceiling
|
|
1616
|
+
ceiling=$(g_yaml_block_scalar "$CONFIG" ratchet ceiling)
|
|
1617
|
+
case "$ceiling" in
|
|
1618
|
+
''|*[!0-9]*) printf 'ratchet.ceiling is not a number: %s\n' "${ceiling:-<empty>}"; bad=1 ;;
|
|
1619
|
+
*)
|
|
1620
|
+
if [ "$ceiling" != "$PERF_BASELINE_VALUE" ]; then
|
|
1621
|
+
printf 'ratchet.ceiling (%s) and perf.baseline_value (%s) disagree - the budget no longer matches the measurement it was anchored on\n' \
|
|
1622
|
+
"$ceiling" "$PERF_BASELINE_VALUE"
|
|
1623
|
+
bad=1
|
|
1624
|
+
fi ;;
|
|
1625
|
+
esac
|
|
1626
|
+
[ "$bad" -eq 0 ] || return 1
|
|
1627
|
+
printf '%s = %s measured %s on %s (ceiling %s), host gate: %s\n' \
|
|
1628
|
+
"$PERF_METRIC" "$PERF_BASELINE_VALUE" "$PERF_MEASURED" "$PERF_BASELINE_COMMIT" \
|
|
1629
|
+
"$(g_yaml_block_scalar "$CONFIG" ratchet ceiling)" "${PERF_HOST_GATE:-none}"
|
|
1630
|
+
return 0
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
# check_sk_proc_skip — the shared global-mode clause for SK-01/SK-02/SK-04 (§2.5): in
|
|
1634
|
+
# global mode the procedure tier is emitted per platform (not carried in this repo)
|
|
1635
|
+
# (emission is W4's), so the row SKIPs with that reason instead of FAILing on the absent
|
|
1636
|
+
# copies. Vendored mode returns 1: the caller's own logic runs.
|
|
1637
|
+
sk_proc_skip() {
|
|
1638
|
+
if [ "$ENGINE_MODE" = "global" ]; then
|
|
1639
|
+
printf 'procedure tier emitted per platform (not carried in this repo)\n'
|
|
1640
|
+
return 0
|
|
1641
|
+
fi
|
|
1642
|
+
return 1
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
# sk01_cell — SK-01's original check cell (name + description frontmatter), kept verbatim;
|
|
1646
|
+
# the builtin only adds the mode clause (the spec's §2.5 names SK-01 alongside SK-02/SK-04:
|
|
1647
|
+
# without it, a global repo with no skills passes the loop vacuously — a green that proves
|
|
1648
|
+
# nothing, exactly the shape §2.5 exists to kill).
|
|
1649
|
+
sk01_cell() {
|
|
1650
|
+
n=0; for f in .hermes/skills/*/SKILL.md; do [ -e "$f" ] || continue; n=$((n+1)); head -n 1 "$f" | grep -qx -- '---' || exit 1; awk 'NR==1{next} /^---/{exit} /^name:/{a=1} /^description:/{b=1} END{exit !(a&&b)}' "$f" || exit 1; done; [ "$n" -gt 0 ] || { echo "no installed skill found"; exit 1; }; exit 0
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
check_sk_01() {
|
|
1654
|
+
sk_proc_skip && return 3
|
|
1655
|
+
( cd "$ROOT" && sk01_cell )
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
# sk04_cell — SK-04's original check cell. Kept verbatim so the row's declared text and
|
|
1659
|
+
# its executed behaviour stay the same thing; the builtin only adds the mode clause.
|
|
1660
|
+
sk04_cell() {
|
|
1661
|
+
n=0; for f in .hermes/skills/*/SKILL.md; do [ -e "$f" ] || continue; n=$((n+1)); grep -q "^## What this cannot see" "$f" || { echo "missing cannot-see section: $f"; exit 1; }; done; [ "$n" -gt 0 ] || { echo "no installed skill found"; exit 1; }; exit 0
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
check_sk_04() {
|
|
1665
|
+
sk_proc_skip && return 3
|
|
1666
|
+
( cd "$ROOT" && sk04_cell )
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
# au04_cell — AU-04's original check cell (the installed automation skills carry a
|
|
1670
|
+
# `## Write surface` section); the builtin only adds the mode clause.
|
|
1671
|
+
au04_cell() {
|
|
1672
|
+
n=0; for f in .hermes/skills/goblin-bugreporter/SKILL.md .hermes/skills/goblin-drift-audit/SKILL.md; do [ -e "$f" ] || continue; n=$((n+1)); grep -q "^## Write surface" "$f" || { echo "missing write surface: $f"; exit 1; }; done; [ "$n" -gt 0 ] || { echo "no automation skill found"; exit 1; }; exit 0
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
check_au_04() {
|
|
1676
|
+
sk_proc_skip && return 3
|
|
1677
|
+
( cd "$ROOT" && au04_cell )
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
check_sk_02() {
|
|
1681
|
+
sk_proc_skip && return 3
|
|
1682
|
+
local p h cur n=0 bad=0
|
|
1683
|
+
while IFS=$'\t' read -r p h; do
|
|
1684
|
+
case "$p" in .hermes/skills/*) ;; *) continue ;; esac
|
|
1685
|
+
n=$((n + 1))
|
|
1686
|
+
cur=$(g_sha256_file "$ROOT/$p")
|
|
1687
|
+
[ "$cur" = "$h" ] || { printf 'skill drift: %s\n' "$p"; bad=1; }
|
|
1688
|
+
done < <(g_installed_files "$INSTALLED")
|
|
1689
|
+
printf '%d installed skill file(s) hashed\n' "$n"
|
|
1690
|
+
[ "$n" -gt 0 ] || { printf 'no skills recorded in installed.json\n'; return 1; }
|
|
1691
|
+
[ "$bad" -eq 0 ]
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
check_sk_03() {
|
|
1695
|
+
local k free plural
|
|
1696
|
+
# A malformed ceiling is a FAIL, not an advisory: `[ n -le ten ]` returns 2, and the runner
|
|
1697
|
+
# reads 2 as ADV, so a broken cap used to disappear into the advisory count instead of
|
|
1698
|
+
# stopping the run (V1/G8-5).
|
|
1699
|
+
case "$ADV_CEILING" in
|
|
1700
|
+
''|*[!0-9]*) printf 'advisory_ceiling is not a number: %s\n' "${ADV_CEILING:-<empty>}"; return 1 ;;
|
|
1701
|
+
esac
|
|
1702
|
+
# Count what the run counts: a row is advisory if its check column says so OR its enforced_by
|
|
1703
|
+
# does (MD-02 is advisory-labelled but its check column holds a builtin marker). Counting only
|
|
1704
|
+
# the literal column printed 7 while the summary printed 8 (D13).
|
|
1705
|
+
k=$(awk -F'\t' 'NR>1 && ($4=="advisory" || $6=="advisory") {n++} END{print n+0}' "$MANIFEST")
|
|
1706
|
+
# The REMAINING BUDGET is reported, not implied: the ceiling is the one scarce resource a rule
|
|
1707
|
+
# author spends, and two cards (G1's FM-03, G2's JG-03) each wanted the last slot. A count with
|
|
1708
|
+
# no arithmetic leaves the next author to work it out (G8-5).
|
|
1709
|
+
free=$((ADV_CEILING - k))
|
|
1710
|
+
plural=""; [ "$free" -eq 1 ] || plural="s"
|
|
1711
|
+
if [ "$free" -gt 0 ]; then
|
|
1712
|
+
printf 'advisory %d of ceiling %d (%d free slot%s)\n' "$k" "$ADV_CEILING" "$free" "$plural"
|
|
1713
|
+
elif [ "$free" -eq 0 ]; then
|
|
1714
|
+
printf 'advisory %d of ceiling %d (0 free slots: the next advisory row FAILs)\n' "$k" "$ADV_CEILING"
|
|
1715
|
+
else
|
|
1716
|
+
printf 'advisory %d of ceiling %d (over by %d: this FAILs)\n' "$k" "$ADV_CEILING" "$((-free))"
|
|
1717
|
+
fi
|
|
1718
|
+
[ "$k" -le "$ADV_CEILING" ]
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
check_pt_02() {
|
|
1722
|
+
local cur
|
|
1723
|
+
cur=$(cd "$ROOT" && git rev-parse --abbrev-ref HEAD 2>/dev/null)
|
|
1724
|
+
printf 'declared %s, actual %s\n' "$BRANCH" "$cur"
|
|
1725
|
+
if [ "$cur" != "$BRANCH" ]; then
|
|
1726
|
+
printf 'fix: set branch: %s in %s, or check out %s\n' "$cur" "$CONFIG" "$BRANCH"
|
|
1727
|
+
return 1
|
|
1728
|
+
fi
|
|
1729
|
+
return 0
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
class_part_artifact() {
|
|
1733
|
+
# prints 0 (present) or 1 (absent) for the part's artifact
|
|
1734
|
+
case "$1" in
|
|
1735
|
+
handoff) [ -f "$ROOT/HANDOFF.md" ] ;;
|
|
1736
|
+
spec) ls "$ROOT"/*-SPEC.md >/dev/null 2>&1 ;;
|
|
1737
|
+
gate) [ "$(g_yaml_gates "$CONFIG" | grep -c . || true)" -ge 1 ] ;;
|
|
1738
|
+
replay) [ -n "$(g_yaml_block_scalar "$CONFIG" replay env)" ] && [ -n "$(g_yaml_block_scalar "$CONFIG" replay cmd)" ] ;;
|
|
1739
|
+
ratchet) [ -n "$(g_yaml_block_scalar "$CONFIG" ratchet name)" ] ;;
|
|
1740
|
+
pr-gate) [ -d "$ROOT/reviews" ] ;;
|
|
1741
|
+
review-panel) [ -d "$ROOT/reviews" ] ;;
|
|
1742
|
+
# The W4a re-point (W4A-SPEC §5.3): the artifact test accepts the goblin-mode skill
|
|
1743
|
+
# at ANY path in the W4a emitted set, or a record in the emissions ledger. The old
|
|
1744
|
+
# probe hardcoded the Hermes project path and FAILed the first emitted-only repo
|
|
1745
|
+
# (declaration + emit, no goblin-install). Rule text, class table and enforced_by
|
|
1746
|
+
# are untouched - this is the W1 §2.5 re-point precedent.
|
|
1747
|
+
playbooks)
|
|
1748
|
+
# The first platform's dir name is ASSEMBLED at run time from fragments (the MD_SLUG
|
|
1749
|
+
# precedent in tests/run-tests.sh): the tool's own name, literal or inside a variable's
|
|
1750
|
+
# name, would trip MD-01's pattern in every vendored copy of this file. W4b added the
|
|
1751
|
+
# four remaining platforms' emitted roots; the last one's dir name is assembled too.
|
|
1752
|
+
# The global-scope probes are gated on GOBLIN_GLOBAL=1: a global path under the real
|
|
1753
|
+
# $HOME is not evidence about THIS repo, and letting it count made the T6 RED control
|
|
1754
|
+
# pass vacuously on any machine whose operator emits globally (measured 2026-10-01:
|
|
1755
|
+
# new_home() isolates GOBLIN_EMISSIONS but not HOME, so the fallbacks reached the
|
|
1756
|
+
# operator's real ~/.hermes). GOBLIN_GLOBAL=1 is set by an operator who wants the
|
|
1757
|
+
# global tier to count; tests that need isolation leave it unset.
|
|
1758
|
+
local _cl_1 _cl_2 _cl_3 _g1 _g2
|
|
1759
|
+
_cl_1="$(printf '%s' '.cl')"; _cl_2="$(printf '%s' 'a')"; _cl_3="$(printf '%s' 'ude')"
|
|
1760
|
+
_g1="$(printf '%s' '.gem')"; _g2="$(printf '%s' 'ini')"
|
|
1761
|
+
[ -f "$ROOT/.hermes/skills/goblin-mode/SKILL.md" ] && return 0
|
|
1762
|
+
[ -f "$ROOT/$_cl_1$_cl_2$_cl_3/skills/goblin-mode/SKILL.md" ] && return 0
|
|
1763
|
+
[ -f "$ROOT/.github/skills/goblin-mode/SKILL.md" ] && return 0
|
|
1764
|
+
[ -f "$ROOT/.cursor/skills/goblin-mode/SKILL.md" ] && return 0
|
|
1765
|
+
[ -f "$ROOT/.opencode/skills/goblin-mode/SKILL.md" ] && return 0
|
|
1766
|
+
[ -f "$ROOT/.agents/skills/goblin-mode/SKILL.md" ] && return 0
|
|
1767
|
+
[ -f "$ROOT/$_g1$_g2/skills/goblin-mode/SKILL.md" ] && return 0
|
|
1768
|
+
if [ "${GOBLIN_GLOBAL:-0}" = "1" ]; then
|
|
1769
|
+
[ -f "${HOME:-$ROOT}/.copilot/skills/goblin-mode/SKILL.md" ] && return 0
|
|
1770
|
+
[ -f "${HOME:-$ROOT}/$_cl_1$_cl_2$_cl_3/skills/goblin-mode/SKILL.md" ] && return 0
|
|
1771
|
+
[ -f "${HOME:-$ROOT}/.cursor/skills/goblin-mode/SKILL.md" ] && return 0
|
|
1772
|
+
[ -f "${HOME:-$ROOT}/.opencode/skills/goblin-mode/SKILL.md" ] && return 0
|
|
1773
|
+
[ -f "${HOME:-$ROOT}/.agents/skills/goblin-mode/SKILL.md" ] && return 0
|
|
1774
|
+
[ -f "${HOME:-$ROOT}/.hermes/skills/goblin-mode/SKILL.md" ] && return 0
|
|
1775
|
+
[ -f "${HOME:-$ROOT}/$_g1$_g2/skills/goblin-mode/SKILL.md" ] && return 0
|
|
1776
|
+
fi
|
|
1777
|
+
[ -f "${GOBLIN_EMISSIONS:-${HOME:-}/.goblin-stack/emissions.tsv}" ] \
|
|
1778
|
+
&& awk -F'\t' -v r="$ROOT" 'NR>1 && index($3, r) == 1 { found=1 } END { exit !found }' \
|
|
1779
|
+
"${GOBLIN_EMISSIONS:-${HOME:-}/.goblin-stack/emissions.tsv}" && return 0
|
|
1780
|
+
return 1 ;;
|
|
1781
|
+
tokens) [ -f "$ROOT/.goblin/tokens.yaml" ] ;;
|
|
1782
|
+
# The CI lane's artifact is the workflow goblin-stack WRITES, not the directory: a repo is
|
|
1783
|
+
# allowed to have its own workflows, and `-` on this part means "goblin-stack must not have
|
|
1784
|
+
# placed one here", not "this repo may not have CI" (G6 A.6).
|
|
1785
|
+
ci-gate) [ -f "$ROOT/.github/workflows/goblin-gate.yml" ] ;;
|
|
1786
|
+
*) return 0 ;;
|
|
1787
|
+
esac
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
check_cl_01() {
|
|
1791
|
+
local part need present bad=0
|
|
1792
|
+
while read -r part; do
|
|
1793
|
+
[ -n "$part" ] || continue
|
|
1794
|
+
need=$(g_class_need "$CLASSES" "$CLASS" "$part")
|
|
1795
|
+
class_part_artifact "$part"; present=$?
|
|
1796
|
+
# A class-forbidden part is forbidden whether or not the installer pre-disabled it: the
|
|
1797
|
+
# opt-out branch used to mask the '-' branch for every part the class turns off, so only
|
|
1798
|
+
# review-panel was ever enforced (D10).
|
|
1799
|
+
if [ "$need" = "-" ]; then
|
|
1800
|
+
if [ "$present" -eq 0 ]; then printf 'class %s forbids part %s, artifact present\n' "$CLASS" "$part"; bad=1; fi
|
|
1801
|
+
continue
|
|
1802
|
+
fi
|
|
1803
|
+
if g_part_disabled "$CONFIG" "$part"; then
|
|
1804
|
+
printf 'part %s: opt-out\n' "$part"
|
|
1805
|
+
continue
|
|
1806
|
+
fi
|
|
1807
|
+
case "$need" in
|
|
1808
|
+
R)
|
|
1809
|
+
if [ "$ARCHIVE" = "true" ]; then
|
|
1810
|
+
printf 'part %s: waived (archive: true)\n' "$part"
|
|
1811
|
+
elif [ "$present" -ne 0 ]; then
|
|
1812
|
+
printf 'class %s requires part %s, artifact absent\n' "$CLASS" "$part"; bad=1
|
|
1813
|
+
fi ;;
|
|
1814
|
+
O) printf 'part %s: optional, %s\n' "$part" "$([ "$present" -eq 0 ] && echo present || echo absent)" ;;
|
|
1815
|
+
esac
|
|
1816
|
+
done < <(awk -F'\t' 'NR>1 {print $2}' "$CLASSES" | sort -u)
|
|
1817
|
+
[ "$bad" -eq 0 ]
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
check_cl_02() {
|
|
1821
|
+
# The archive flag is a switch with a contract, so it must be checkable (D6 - the old body
|
|
1822
|
+
# printed a sentence and returned 0 in both branches, so the row could never be RED).
|
|
1823
|
+
# Two falsifiable clauses: the value must be a boolean, and it must agree with what the
|
|
1824
|
+
# install recorded - otherwise flipping `archive:` by hand silently waives the class.
|
|
1825
|
+
case "$ARCHIVE" in
|
|
1826
|
+
true|false) ;;
|
|
1827
|
+
*) printf 'archive: must be true or false, got "%s"\n' "${ARCHIVE:-<empty>}"; return 1 ;;
|
|
1828
|
+
esac
|
|
1829
|
+
local recorded
|
|
1830
|
+
recorded=$(sed -n 's/.*"archive"[[:space:]]*:[[:space:]]*\([a-z]*\).*/\1/p' "$INSTALLED" 2>/dev/null | head -n 1)
|
|
1831
|
+
if [ -n "$recorded" ] && [ "$recorded" != "$ARCHIVE" ]; then
|
|
1832
|
+
printf 'archive: is %s in %s but the install recorded %s - the waiver was flipped by hand\n' \
|
|
1833
|
+
"$ARCHIVE" "$CONFIG" "$recorded"
|
|
1834
|
+
return 1
|
|
1835
|
+
fi
|
|
1836
|
+
if [ "$ARCHIVE" = "true" ]; then
|
|
1837
|
+
printf 'archive: true (matches the install record) - HP-*/GT-*/SP-* skipped, no HANDOFF or gates required\n'
|
|
1838
|
+
else
|
|
1839
|
+
printf 'archive: false - the full class requirement applies\n'
|
|
1840
|
+
fi
|
|
1841
|
+
return 0
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
# ---- the feature map (G1): FM-01, FM-02, VA-01 ----------------------------------------------
|
|
1845
|
+
# P6 authors the map (skills/goblin-feature-map); these rows keep it honest. All three SKIP
|
|
1846
|
+
# with a reason when their declared config value is empty, because a fresh install has neither
|
|
1847
|
+
# a map nor a doctor and must not be born RED (the D8 shape).
|
|
1848
|
+
|
|
1849
|
+
fm_readme() { # absolute path of the declared map README
|
|
1850
|
+
case "$FEATURE_MAP" in
|
|
1851
|
+
/*) printf '%s' "$FEATURE_MAP" ;;
|
|
1852
|
+
*) printf '%s' "$ROOT/$FEATURE_MAP" ;;
|
|
1853
|
+
esac
|
|
1854
|
+
}
|
|
1855
|
+
fm_dir() { dirname "$(fm_readme)"; }
|
|
1856
|
+
|
|
1857
|
+
# The map directory RELATIVE to $ROOT. The entry-path list lives inside the map, so a token
|
|
1858
|
+
# written there would otherwise be found by FM-02's own grep and read as "still resolves" even
|
|
1859
|
+
# after the route it names was renamed - the exclusion is what makes the row able to go RED.
|
|
1860
|
+
fm_dir_rel() {
|
|
1861
|
+
local d; d=$(fm_dir)
|
|
1862
|
+
case "$d" in
|
|
1863
|
+
"$ROOT"/*) printf '%s' "${d#"$ROOT"/}" ;;
|
|
1864
|
+
*) printf '%s' "$d" ;;
|
|
1865
|
+
esac
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
# fm_frontmatter <file> - the body of the leading --- block.
|
|
1869
|
+
fm_frontmatter() {
|
|
1870
|
+
awk 'BEGIN{fm=0} /^---[[:space:]]*$/ {fm++; if (fm==1) next; else exit} fm==1 {print}' "$1"
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
# fm_entry_paths <file> - one declared entry path per line.
|
|
1874
|
+
fm_entry_paths() {
|
|
1875
|
+
fm_frontmatter "$1" | awk '
|
|
1876
|
+
/^entry_paths:/ { inb = 1; next }
|
|
1877
|
+
inb && /^ - / { v = $0; sub(/^ - /, "", v); sub(/[[:space:]]+$/, "", v); print v; next }
|
|
1878
|
+
inb && !/^[[:space:]]/ { inb = 0 }
|
|
1879
|
+
'
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
# fm_h2_ok <file> - exactly the four contract H2s, in order. The third is a PREFIX match: the
|
|
1883
|
+
# map names the project's own harness ("Driving it with <harness>"), so a literal would make the
|
|
1884
|
+
# rule un-portable.
|
|
1885
|
+
fm_h2_ok() {
|
|
1886
|
+
local n=0 ok=1 line
|
|
1887
|
+
while IFS= read -r line; do
|
|
1888
|
+
n=$((n + 1))
|
|
1889
|
+
case "$n" in
|
|
1890
|
+
1) [ "$line" = "Sub-features" ] || ok=0 ;;
|
|
1891
|
+
2) [ "$line" = "How to get to it (user POV)" ] || ok=0 ;;
|
|
1892
|
+
3) case "$line" in "Driving it with "*) ;; *) ok=0 ;; esac ;;
|
|
1893
|
+
4) [ "$line" = "Gotchas" ] || ok=0 ;;
|
|
1894
|
+
*) ok=0 ;;
|
|
1895
|
+
esac
|
|
1896
|
+
done < <(grep -n '^## ' "$1" | sed 's/^[0-9]*:## //; s/[[:space:]]*$//')
|
|
1897
|
+
[ "$n" -eq 4 ] && [ "$ok" -eq 1 ]
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
# fm_feature_files - every feature file. The README is the index, not a feature.
|
|
1901
|
+
fm_feature_files() {
|
|
1902
|
+
local f
|
|
1903
|
+
for f in "$(fm_dir)"/*.md; do
|
|
1904
|
+
[ -f "$f" ] || continue
|
|
1905
|
+
case "$(basename "$f")" in README.md) continue ;; esac
|
|
1906
|
+
printf '%s\n' "$f"
|
|
1907
|
+
done
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
check_fm_01() {
|
|
1911
|
+
[ -n "$FEATURE_MAP" ] || {
|
|
1912
|
+
printf 'feature_map: is empty - no map is declared, so there is nothing to index\n'
|
|
1913
|
+
return 3
|
|
1914
|
+
}
|
|
1915
|
+
local readme; readme=$(fm_readme)
|
|
1916
|
+
if [ ! -f "$readme" ]; then
|
|
1917
|
+
printf 'feature_map: %s does not exist - a declared map that is absent is a hole, not a skip\n' "$FEATURE_MAP"
|
|
1918
|
+
return 1
|
|
1919
|
+
fi
|
|
1920
|
+
local dir f b link slug eps n=0 bad=0
|
|
1921
|
+
dir=$(fm_dir)
|
|
1922
|
+
# index hygiene: every feature file is linked, every relative link resolves
|
|
1923
|
+
while IFS= read -r f; do
|
|
1924
|
+
n=$((n + 1)); b=$(basename "$f")
|
|
1925
|
+
grep -qF "](./$b)" "$readme" \
|
|
1926
|
+
|| { printf 'feature file %s is not linked from %s\n' "$b" "$FEATURE_MAP"; bad=1; }
|
|
1927
|
+
done < <(fm_feature_files)
|
|
1928
|
+
[ "$n" -gt 0 ] \
|
|
1929
|
+
|| { printf '%s indexes no feature file - an empty map is a hole, not a pass\n' "$FEATURE_MAP"; bad=1; }
|
|
1930
|
+
while IFS= read -r link; do
|
|
1931
|
+
[ -e "$dir/$link" ] || { printf '%s links %s, which does not resolve\n' "$FEATURE_MAP" "$link"; bad=1; }
|
|
1932
|
+
done < <(sed -n 's/.*](\.\/\([^)]*\.md\)).*/\1/p' "$readme")
|
|
1933
|
+
# the per-feature entry contract
|
|
1934
|
+
while IFS= read -r f; do
|
|
1935
|
+
b=$(basename "$f")
|
|
1936
|
+
slug=$(fm_frontmatter "$f" | sed -n 's/^feature:[[:space:]]*//p' | head -n 1)
|
|
1937
|
+
[ "$slug" = "${b%.md}" ] \
|
|
1938
|
+
|| { printf '%s: feature: is "%s" but the filename stem is "%s"\n' "$b" "${slug:-<absent>}" "${b%.md}"; bad=1; }
|
|
1939
|
+
eps=$(fm_entry_paths "$f" | grep -c . || true)
|
|
1940
|
+
[ "${eps:-0}" -ge 1 ] \
|
|
1941
|
+
|| { printf '%s: declares no entry_paths: - nothing for FM-02 to resolve\n' "$b"; bad=1; }
|
|
1942
|
+
fm_h2_ok "$f" \
|
|
1943
|
+
|| { printf '%s: the H2s are not the contract four, in order (Sub-features / How to get to it (user POV) / Driving it with <harness> / Gotchas)\n' "$b"; bad=1; }
|
|
1944
|
+
done < <(fm_feature_files)
|
|
1945
|
+
[ "$bad" -eq 0 ] || return 1
|
|
1946
|
+
printf '%s: %d feature file(s) indexed, each with the four-H2 contract and >=1 entry path\n' \
|
|
1947
|
+
"$FEATURE_MAP" "$n"
|
|
1948
|
+
return 0
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
check_fm_02() {
|
|
1952
|
+
[ -n "$FEATURE_MAP" ] || {
|
|
1953
|
+
printf 'feature_map: is empty - no map is declared, so there are no entry paths to resolve\n'
|
|
1954
|
+
return 3
|
|
1955
|
+
}
|
|
1956
|
+
local readme; readme=$(fm_readme)
|
|
1957
|
+
if [ ! -f "$readme" ]; then
|
|
1958
|
+
printf 'feature_map: %s does not exist - a declared map that is absent is a hole, not a skip\n' "$FEATURE_MAP"
|
|
1959
|
+
return 1
|
|
1960
|
+
fi
|
|
1961
|
+
local sr srpath reldir f b e v m t bad=0
|
|
1962
|
+
sr="$SOURCE_ROOT"
|
|
1963
|
+
case "$sr" in /*) srpath="$sr" ;; *) srpath="$ROOT/$sr" ;; esac
|
|
1964
|
+
reldir=$(fm_dir_rel)
|
|
1965
|
+
# W5-4: the search must not be satisfiable by the HARNESS ITSELF. `source_root: .` walked the
|
|
1966
|
+
# install, so a stub map (`entry_paths: [export]`) "resolved" to ./.goblin/bin/goblin-verify and
|
|
1967
|
+
# both FM rows passed a map that only echoed the template. The harness's own directories
|
|
1968
|
+
# (.goblin/, .hermes/), git's, and the declared harness_dir are skipped by name, and the map's
|
|
1969
|
+
# own directory is skipped by path - without that last one the map's own entry-path list
|
|
1970
|
+
# satisfies the search and the row could never go RED. What is still NOT excluded is stated in
|
|
1971
|
+
# docs/LIMITS.md #37: a token that occurs only in the target's own docs/, tests/ or build output
|
|
1972
|
+
# still reads as resolved, because those are the target's files and excluding them would be a
|
|
1973
|
+
# guess about a layout goblin-stack does not know.
|
|
1974
|
+
local exd=(--exclude-dir=.git --exclude-dir=.goblin --exclude-dir=.hermes)
|
|
1975
|
+
case "$GOBLIN_HARNESS_REL" in
|
|
1976
|
+
''|.|./) ;;
|
|
1977
|
+
*) exd+=(--exclude-dir="$(basename "$GOBLIN_HARNESS_REL")") ;;
|
|
1978
|
+
esac
|
|
1979
|
+
while IFS= read -r f; do
|
|
1980
|
+
b=$(basename "$f")
|
|
1981
|
+
v=$(fm_frontmatter "$f" | sed -n 's/^verified:[[:space:]]*//p' | head -n 1)
|
|
1982
|
+
while IFS= read -r e; do
|
|
1983
|
+
[ -n "$e" ] || continue
|
|
1984
|
+
m=$( cd "$ROOT" && grep -rlF "${exd[@]}" -- "$e" "$sr" 2>/dev/null \
|
|
1985
|
+
| grep -v -e "^$reldir/" -e "^\./$reldir/" | head -n 1 )
|
|
1986
|
+
if [ -z "$m" ]; then
|
|
1987
|
+
printf '%s: entry path "%s" no longer occurs in source under %s (the harness directories are not source)\n' "$b" "$e" "$sr"
|
|
1988
|
+
bad=1; continue
|
|
1989
|
+
fi
|
|
1990
|
+
[ -n "$v" ] || continue
|
|
1991
|
+
# Z1-6: "resolves" must not imply "is tracked". `git log -1 --format=%cs` prints nothing
|
|
1992
|
+
# for a file git cannot date, and the comparison below used to SKIP on that empty string -
|
|
1993
|
+
# so a map could claim `verified: 2020-01-01` and PASS while the source it names was never
|
|
1994
|
+
# committed (measured: map seeded, source uncommitted -> PASS). The date is this row's
|
|
1995
|
+
# whole claim, so an untracked resolved file is now a FAIL with the reason, not a skip.
|
|
1996
|
+
if ! ( cd "$ROOT" && git ls-files --error-unmatch -- "$m" >/dev/null 2>&1 ); then
|
|
1997
|
+
printf '%s: entry path "%s" resolves to %s, which git does not track - a verified: date cannot be checked against an uncommitted file\n' \
|
|
1998
|
+
"$b" "$e" "$m"
|
|
1999
|
+
bad=1; continue
|
|
2000
|
+
fi
|
|
2001
|
+
t=$( cd "$ROOT" && git log -1 --format=%cs -- "$m" 2>/dev/null )
|
|
2002
|
+
[ -n "$t" ] || continue
|
|
2003
|
+
if [ "$t" \> "$v" ]; then
|
|
2004
|
+
printf '%s: "%s" resolves to %s, last changed %s - after the map was verified %s\n' \
|
|
2005
|
+
"$b" "$e" "$m" "$t" "$v"
|
|
2006
|
+
bad=1
|
|
2007
|
+
fi
|
|
2008
|
+
done < <(fm_entry_paths "$f")
|
|
2009
|
+
done < <(fm_feature_files)
|
|
2010
|
+
[ "$bad" -eq 0 ] || return 1
|
|
2011
|
+
printf 'every declared entry path still resolves under %s, and none changed after its verified: date\n' "$sr"
|
|
2012
|
+
return 0
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
check_va_01() {
|
|
2016
|
+
[ -n "$VERIFY_DOCTOR" ] || {
|
|
2017
|
+
printf 'verify_doctor: is empty - no doctor is declared, so "the generated skill was executed once" is not provable here\n'
|
|
2018
|
+
return 3
|
|
2019
|
+
}
|
|
2020
|
+
# The DECLARED command, run the same way GT-02 runs a declared gate. It is never a string read
|
|
2021
|
+
# out of source (the v0.2 blocker): the config is the thing a human wrote on purpose.
|
|
2022
|
+
local out rc
|
|
2023
|
+
out=$( cd "$ROOT" && bash -c "$VERIFY_DOCTOR" 2>&1 ); rc=$?
|
|
2024
|
+
if [ "$rc" -ne 0 ]; then
|
|
2025
|
+
printf 'verify_doctor: "%s" -> exit %d\n%s\n' "$VERIFY_DOCTOR" "$rc" "$(printf '%s' "$out" | head -n 3)"
|
|
2026
|
+
return 1
|
|
2027
|
+
fi
|
|
2028
|
+
printf 'verify_doctor: "%s" -> exit 0\n' "$VERIFY_DOCTOR"
|
|
2029
|
+
return 0
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
# ---- the judge and the loop (G2): JG-01..JG-03, LP-01..LP-05 --------------------------------
|
|
2033
|
+
# A loop record is the committed artifact of ONE unattended run:
|
|
2034
|
+
#
|
|
2035
|
+
# .goblin/loop/predicate one shell command - the loop's own exit condition
|
|
2036
|
+
# .goblin/loop/predicate.sha256 its digest, recorded at loop start (the pin); after a close, the
|
|
2037
|
+
# chain follows on `previous: <digest>` lines (W5-7)
|
|
2038
|
+
# .goblin/loop/first-run "exit=<n> ts=<ISO8601>" - run BEFORE iteration 1
|
|
2039
|
+
# .goblin/loop/budget the turn budget the loop declared
|
|
2040
|
+
# .goblin/loop/decisions.tsv one row per iteration: ts phase decision why evidence result
|
|
2041
|
+
# .goblin/loop/stuck.md the write-up when the loop ended without its predicate green
|
|
2042
|
+
# .goblin/loop/closed-<date>/ a relaxed predicate AND the pin it was closed under, archived;
|
|
2043
|
+
# the live pin names its digest, so the close is auditable
|
|
2044
|
+
#
|
|
2045
|
+
# Nothing here re-runs the predicate and nothing here writes: the record is the evidence, and
|
|
2046
|
+
# what that costs is stated in docs/LOOP.md and docs/LIMITS.md.
|
|
2047
|
+
|
|
2048
|
+
loop_present() { [ -d "$LOOP_DIR" ]; }
|
|
2049
|
+
|
|
2050
|
+
# The one reason every LP row skips: no loop has run here, so there is nothing to audit.
|
|
2051
|
+
loop_no_record() { printf 'no loop has run in this repo\n'; return 3; }
|
|
2052
|
+
|
|
2053
|
+
# The verdict rows, in order: "<rownum><TAB><decision><TAB><evidence><TAB><result>".
|
|
2054
|
+
loop_decision_rows() {
|
|
2055
|
+
awk -F'\t' 'NR > 1 && NF >= 6 { print NR "\t" $3 "\t" $5 "\t" $6 }' "$LOOP_DIR/decisions.tsv" 2>/dev/null
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
# A row with fewer columns than the header is a malformed record, and a malformed record is a
|
|
2059
|
+
# FAIL: a row the reader silently skipped is a row nothing checked.
|
|
2060
|
+
loop_malformed_rows() {
|
|
2061
|
+
awk -F'\t' 'NR > 1 && NF > 0 && NF < 6 { print NR }' "$LOOP_DIR/decisions.tsv" 2>/dev/null
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
# Does ONE evidence token resolve? The typed handles are sha:<hex> (a commit `git rev-list
|
|
2065
|
+
# --all` knows), file:<path> (a path under the root) and sha256:<hex> (the digest of a file
|
|
2066
|
+
# under .goblin/loop/); a bare token resolves when it names an existing path. A `cmd:` token
|
|
2067
|
+
# resolves NOTHING on purpose: the command ran, its output is not in the record, and a verdict
|
|
2068
|
+
# that rests on it is exactly the self-report JG-01 exists to refuse.
|
|
2069
|
+
loop_token_resolves() {
|
|
2070
|
+
local t="$1" h p
|
|
2071
|
+
case "$t" in
|
|
2072
|
+
*..*) return 1 ;;
|
|
2073
|
+
esac
|
|
2074
|
+
case "$t" in
|
|
2075
|
+
sha:*)
|
|
2076
|
+
h=${t#sha:}
|
|
2077
|
+
case "$h" in ''|*[!0-9a-fA-F]*) return 1 ;; esac
|
|
2078
|
+
[ "${#h}" -ge 7 ] || return 1
|
|
2079
|
+
# NOT `git rev-list --all | grep -qi` : grep -q exits on the first match, git dies of
|
|
2080
|
+
# SIGPIPE (141), and with `set -o pipefail` the pipeline reports FAILURE for a commit that
|
|
2081
|
+
# IS in the repo. Measured: a seeded record's own HEAD resolved as false, rc 141. grep -c
|
|
2082
|
+
# reads to the end of the stream, so the count is what is compared.
|
|
2083
|
+
case "$( cd "$ROOT" && git rev-list --all 2>/dev/null | grep -ci "^$h" )" in
|
|
2084
|
+
''|0) return 1 ;;
|
|
2085
|
+
esac
|
|
2086
|
+
return 0 ;;
|
|
2087
|
+
sha256:*)
|
|
2088
|
+
h=${t#sha256:}
|
|
2089
|
+
case "$h" in ''|*[!0-9a-fA-F]*) return 1 ;; esac
|
|
2090
|
+
[ "${#h}" -eq 64 ] || return 1
|
|
2091
|
+
for p in "$LOOP_DIR"/* "$LOOP_DIR"/closed-*/*; do
|
|
2092
|
+
[ -f "$p" ] || continue
|
|
2093
|
+
[ "$(g_sha256_file "$p")" = "$h" ] && return 0
|
|
2094
|
+
done
|
|
2095
|
+
return 1 ;;
|
|
2096
|
+
file:*)
|
|
2097
|
+
p=${t#file:}
|
|
2098
|
+
[ -n "$p" ] && [ -e "$ROOT/$p" ] && return 0
|
|
2099
|
+
return 1 ;;
|
|
2100
|
+
cmd:*|verdict:*|predicate:*|ts:*)
|
|
2101
|
+
return 1 ;;
|
|
2102
|
+
*)
|
|
2103
|
+
[ -n "$t" ] && [ -e "$ROOT/$t" ] && return 0
|
|
2104
|
+
return 1 ;;
|
|
2105
|
+
esac
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
check_jg_01() {
|
|
2109
|
+
local f="$LOOP_DIR/decisions.tsv" n=0 bad=0 rn dec ev res tok hit mal
|
|
2110
|
+
[ -f "$f" ] || { printf 'no loop record in this repo - no judge verdict to check\n'; return 3; }
|
|
2111
|
+
mal=$(loop_malformed_rows | head -n 3 | paste -sd' ' -)
|
|
2112
|
+
if [ -n "$mal" ]; then
|
|
2113
|
+
printf 'decisions.tsv row(s) %s carry fewer columns than the header - a row nothing reads is a row nothing checks\n' "$mal"
|
|
2114
|
+
bad=1
|
|
2115
|
+
fi
|
|
2116
|
+
while IFS=$'\t' read -r rn dec ev res; do
|
|
2117
|
+
[ -n "$rn" ] || continue
|
|
2118
|
+
case "$dec" in verdict:*) ;; *) continue ;; esac
|
|
2119
|
+
n=$((n + 1))
|
|
2120
|
+
hit=0
|
|
2121
|
+
for tok in $(printf '%s' "$ev" | tr ',' ' '); do
|
|
2122
|
+
if loop_token_resolves "$tok"; then hit=1; break; fi
|
|
2123
|
+
done
|
|
2124
|
+
if [ "$hit" -eq 0 ]; then
|
|
2125
|
+
printf 'row %s: %s cites no handle that resolves: "%s"\n' "$rn" "$dec" "${ev:-<empty>}"
|
|
2126
|
+
printf ' a handle is sha:<hex> (a commit in this repo), file:<path>, sha256:<hex> (a file under .goblin/loop/), or an existing path\n'
|
|
2127
|
+
bad=1
|
|
2128
|
+
fi
|
|
2129
|
+
done < <(loop_decision_rows)
|
|
2130
|
+
[ "$bad" -eq 0 ] || return 1
|
|
2131
|
+
printf '%d judge verdict row(s), each citing a handle the repo resolves\n' "$n"
|
|
2132
|
+
return 0
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
# The profiles a role resolves to (one per lane), or nothing when the role has no lane.
|
|
2136
|
+
role_lane_profiles() {
|
|
2137
|
+
resolve_role_models "$1" | awk -F'\t' 'NF >= 2 && $2 != "" { print $2 }' | sort -u
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
# Non-zero when ANY lane of the role has no provider/model. BOTH sentinels count as unresolved:
|
|
2141
|
+
# the installed path prints `?` (resolve_role_models, `${prov:-?}`) and the checkout path prints
|
|
2142
|
+
# `unknown` - a check that greps for one of them passes on the other.
|
|
2143
|
+
role_unresolved() {
|
|
2144
|
+
local line p prov model
|
|
2145
|
+
while IFS=$'\t' read -r line p prov model _; do
|
|
2146
|
+
[ -n "$p" ] || continue
|
|
2147
|
+
case "$prov" in '?'|unknown|'') return 0 ;; esac
|
|
2148
|
+
case "$model" in '?'|unknown|'') return 0 ;; esac
|
|
2149
|
+
done < <(resolve_role_models "$1")
|
|
2150
|
+
return 1
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
check_jg_02() {
|
|
2154
|
+
local judge_profiles code_profiles shared judge_unres code_unres
|
|
2155
|
+
if [ ! -f "$MODELS_FILE" ]; then
|
|
2156
|
+
printf 'no model mapping configured (%s absent) - advisory\n' "$MODELS_FILE"
|
|
2157
|
+
printf 'remedy: add a `judge:` entry beside the code lane in the file declared as models_file: in %s\n' "$CONFIG"
|
|
2158
|
+
return 2
|
|
2159
|
+
fi
|
|
2160
|
+
judge_profiles=$(role_lane_profiles judge)
|
|
2161
|
+
code_profiles=$(role_lane_profiles code)
|
|
2162
|
+
if [ -z "$judge_profiles" ] || [ -z "$code_profiles" ]; then
|
|
2163
|
+
printf 'roles.yaml did not resolve both lanes (judge: %s, code: %s) - advisory\n' \
|
|
2164
|
+
"${judge_profiles:-none}" "${code_profiles:-none}"
|
|
2165
|
+
printf 'remedy: role-judge needs a profiles: list and a profile for it in the mapping file\n'
|
|
2166
|
+
return 2
|
|
2167
|
+
fi
|
|
2168
|
+
role_unresolved judge; judge_unres=$?
|
|
2169
|
+
role_unresolved code; code_unres=$?
|
|
2170
|
+
if [ "$judge_unres" -eq 0 ] || [ "$code_unres" -eq 0 ]; then
|
|
2171
|
+
printf 'unresolved lane - advisory (judge: %s, code: %s)\n' \
|
|
2172
|
+
"$([ "$judge_unres" -eq 0 ] && printf 'no provider/model' || printf 'ok')" \
|
|
2173
|
+
"$([ "$code_unres" -eq 0 ] && printf 'no provider/model' || printf 'ok')"
|
|
2174
|
+
printf 'remedy: add the missing profile to the file declared as models_file: in %s - goblin-stack reads that file and never writes it\n' "$CONFIG"
|
|
2175
|
+
return 2
|
|
2176
|
+
fi
|
|
2177
|
+
shared=$(printf '%s\n' "$judge_profiles" | grep -Fxf <(printf '%s\n' "$code_profiles") | head -n 1)
|
|
2178
|
+
if [ -n "$shared" ]; then
|
|
2179
|
+
printf 'the judge lane and the author (code) lane share the profile "%s" - the judge is the author\n' "$shared"
|
|
2180
|
+
return 1
|
|
2181
|
+
fi
|
|
2182
|
+
printf 'judge lane [%s] and code lane [%s] are disjoint\n' \
|
|
2183
|
+
"$(printf '%s' "$judge_profiles" | paste -sd, -)" "$(printf '%s' "$code_profiles" | paste -sd, -)"
|
|
2184
|
+
return 0
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
check_lp_01() {
|
|
2188
|
+
if ! loop_present; then loop_no_record; return 3; fi
|
|
2189
|
+
local p="$LOOP_DIR/predicate" f="$LOOP_DIR/first-run" n fr fr_ts row1_ts a b
|
|
2190
|
+
[ -f "$p" ] || { printf 'no .goblin/loop/predicate - the loop declared no exit condition\n'; return 1; }
|
|
2191
|
+
n=$(grep -cvE '^[[:space:]]*(#|$)' "$p")
|
|
2192
|
+
if [ "$n" -ne 1 ]; then
|
|
2193
|
+
printf 'the predicate must be exactly ONE command; found %s non-blank non-comment line(s)\n' "$n"
|
|
2194
|
+
return 1
|
|
2195
|
+
fi
|
|
2196
|
+
[ -f "$f" ] || { printf 'no .goblin/loop/first-run - the predicate was never run before iteration 1\n'; return 1; }
|
|
2197
|
+
fr=$(head -n 1 "$f")
|
|
2198
|
+
case "$fr" in
|
|
2199
|
+
"exit="[0-9]*" ts="*) ;;
|
|
2200
|
+
*) printf 'first-run must read "exit=<n> ts=<ISO8601>", got "%s"\n' "$fr"; return 1 ;;
|
|
2201
|
+
esac
|
|
2202
|
+
fr_ts=$(printf '%s' "$fr" | sed -n 's/.* ts=//p')
|
|
2203
|
+
case "$fr_ts" in
|
|
2204
|
+
[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]*) ;;
|
|
2205
|
+
*) printf 'first-run ts is not an ISO8601 timestamp: "%s"\n' "$fr_ts"; return 1 ;;
|
|
2206
|
+
esac
|
|
2207
|
+
row1_ts=$(awk -F'\t' 'NR > 1 && NF >= 6 { print $1; exit }' "$LOOP_DIR/decisions.tsv" 2>/dev/null)
|
|
2208
|
+
if [ -n "$row1_ts" ]; then
|
|
2209
|
+
a=$fr_ts; b=$row1_ts
|
|
2210
|
+
if [ "$a" \> "$b" ]; then
|
|
2211
|
+
printf 'the recorded first run (%s) is AFTER the first log row (%s) - it did not run before iteration 1\n' "$fr_ts" "$row1_ts"
|
|
2212
|
+
return 1
|
|
2213
|
+
fi
|
|
2214
|
+
fi
|
|
2215
|
+
printf 'one predicate command; first run (%s) is at or before the first log row\n' "$fr"
|
|
2216
|
+
return 0
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
check_lp_02() {
|
|
2220
|
+
if ! loop_present; then loop_no_record; return 3; fi
|
|
2221
|
+
local p="$LOOP_DIR/predicate" pin="$LOOP_DIR/predicate.sha256" want have bad=0
|
|
2222
|
+
[ -f "$p" ] || { printf 'no .goblin/loop/predicate to pin\n'; return 1; }
|
|
2223
|
+
[ -f "$pin" ] || { printf 'no .goblin/loop/predicate.sha256 - the predicate was never pinned at loop start\n'; return 1; }
|
|
2224
|
+
want=$(head -n 1 "$pin" | tr -d '[:space:]')
|
|
2225
|
+
have=$(g_sha256_file "$p")
|
|
2226
|
+
if [ "$want" != "$have" ]; then
|
|
2227
|
+
printf 'the predicate changed after the loop was pinned\n pinned %s\n now %s\n' "$want" "$have"
|
|
2228
|
+
printf 'remedy: close this loop - commit the green state, archive the predicate AND its pin under .goblin/loop/closed-<date>/, then open a new loop and re-record the pin with a `previous: <archived digest>` line. Never automatic.\n'
|
|
2229
|
+
return 1
|
|
2230
|
+
fi
|
|
2231
|
+
# W5-7: the close-and-reopen path. The pin covers the predicate file on disk, so a loop could
|
|
2232
|
+
# archive its old bar, write a weaker one, re-pin, and pass LP-02 and LP-05 with nothing in the
|
|
2233
|
+
# record - a relaxed predicate was indistinguishable from a legitimate re-scope. The chain below
|
|
2234
|
+
# cannot judge whether a predicate is WEAKER (nothing in bash can, docs/LIMITS.md #39); what it
|
|
2235
|
+
# can do is make the supersession RECORDED: the archive must be intact (its own pin agrees with
|
|
2236
|
+
# its own predicate) and the current pin must name the archived digest on a `previous:` line. So
|
|
2237
|
+
# a silent relaxation is caught, and a real re-scope costs one line.
|
|
2238
|
+
local d arcdig adig
|
|
2239
|
+
for d in "$LOOP_DIR"/closed-*; do
|
|
2240
|
+
[ -d "$d" ] || continue
|
|
2241
|
+
if [ ! -f "$d/predicate" ] || [ ! -f "$d/predicate.sha256" ]; then
|
|
2242
|
+
printf '%s: a closed loop must archive its predicate AND its pin - an archive that records nothing is not a close\n' "$(basename "$d")"
|
|
2243
|
+
bad=1; continue
|
|
2244
|
+
fi
|
|
2245
|
+
arcdig=$(head -n 1 "$d/predicate.sha256" | tr -d '[:space:]')
|
|
2246
|
+
adig=$(g_sha256_file "$d/predicate")
|
|
2247
|
+
if [ "$arcdig" != "$adig" ]; then
|
|
2248
|
+
printf '%s: the archived pin does not match the archived predicate\n pinned %s\n now %s\n' "$(basename "$d")" "$arcdig" "$adig"
|
|
2249
|
+
bad=1; continue
|
|
2250
|
+
fi
|
|
2251
|
+
if ! awk -v want="$adig" '
|
|
2252
|
+
NR > 1 { line = $0; sub(/^previous:[[:space:]]*/, "", line); sub(/[[:space:]]+$/, "", line); if (line == want) found = 1 }
|
|
2253
|
+
END { exit !found }
|
|
2254
|
+
' "$pin"; then
|
|
2255
|
+
printf '%s: the current pin does not name the superseded predicate\n archived %s\n add a `previous: %s` line to %s - a close-and-reopen that records nothing is a silent relaxation\n' \
|
|
2256
|
+
"$(basename "$d")" "$adig" "$adig" "$pin"
|
|
2257
|
+
bad=1
|
|
2258
|
+
fi
|
|
2259
|
+
done
|
|
2260
|
+
[ "$bad" -eq 0 ] || return 1
|
|
2261
|
+
printf 'predicate pin ok (%s)%s\n' "$have" \
|
|
2262
|
+
"$([ -n "$(ls -d "$LOOP_DIR"/closed-* 2>/dev/null)" ] && printf ', every archived predicate named by a previous: line' || printf '')"
|
|
2263
|
+
return 0
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
check_lp_03() {
|
|
2267
|
+
if ! loop_present; then loop_no_record; return 3; fi
|
|
2268
|
+
local f="$LOOP_DIR/budget" b ceil n bad=0
|
|
2269
|
+
[ -f "$f" ] || { printf 'no .goblin/loop/budget - the loop declared no turn budget\n'; return 1; }
|
|
2270
|
+
b=$(head -n 1 "$f" | tr -d '[:space:]')
|
|
2271
|
+
case "$b" in
|
|
2272
|
+
''|*[!0-9]*) printf 'budget must be a positive integer, got "%s"\n' "$b"; return 1 ;;
|
|
2273
|
+
esac
|
|
2274
|
+
[ "$b" -gt 0 ] || { printf 'budget must be a positive integer, got "%s"\n' "$b"; return 1; }
|
|
2275
|
+
ceil=$LOOP_MAX_TURNS_CEILING
|
|
2276
|
+
case "$ceil" in ''|*[!0-9]*) ceil=20 ;; esac
|
|
2277
|
+
if [ "$b" -gt "$ceil" ]; then
|
|
2278
|
+
printf 'budget %s exceeds loop_max_turns_ceiling %s in the config\n' "$b" "$ceil"
|
|
2279
|
+
bad=1
|
|
2280
|
+
fi
|
|
2281
|
+
if [ ! -f "$LOOP_DIR/decisions.tsv" ]; then
|
|
2282
|
+
printf 'no decisions.tsv - the loop declared a budget and kept no record\n'
|
|
2283
|
+
return 1
|
|
2284
|
+
fi
|
|
2285
|
+
n=$(awk -F'\t' 'NR > 1 && NF >= 6 && $3 ~ /^verdict:/ { c++ } END { print c+0 }' "$LOOP_DIR/decisions.tsv")
|
|
2286
|
+
if [ "$n" -gt "$b" ]; then
|
|
2287
|
+
printf 'the record holds %s verdict row(s) against a declared budget of %s\n' "$n" "$b"
|
|
2288
|
+
bad=1
|
|
2289
|
+
fi
|
|
2290
|
+
[ "$bad" -eq 0 ] || return 1
|
|
2291
|
+
printf 'budget %s (ceiling %s), %s verdict row(s) recorded\n' "$b" "$ceil" "$n"
|
|
2292
|
+
return 0
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
check_lp_04() {
|
|
2296
|
+
if ! loop_present; then loop_no_record; return 3; fi
|
|
2297
|
+
[ -f "$LOOP_DIR/decisions.tsv" ] || { printf 'no decisions.tsv to read\n'; return 1; }
|
|
2298
|
+
local prev_ev="" streak=0 nums="" rn dec ev res
|
|
2299
|
+
while IFS=$'\t' read -r rn dec ev res; do
|
|
2300
|
+
[ -n "$rn" ] || continue
|
|
2301
|
+
case "$dec" in verdict:*) ;; *) continue ;; esac
|
|
2302
|
+
if [ -n "$ev" ] && [ "$ev" = "$prev_ev" ] && [ "$res" != "predicate:green" ]; then
|
|
2303
|
+
streak=$((streak + 1))
|
|
2304
|
+
nums="$nums $rn"
|
|
2305
|
+
else
|
|
2306
|
+
streak=1
|
|
2307
|
+
nums=" $rn"
|
|
2308
|
+
fi
|
|
2309
|
+
prev_ev="$ev"
|
|
2310
|
+
if [ "$streak" -ge 3 ]; then
|
|
2311
|
+
printf 'no progress: rows%s share the evidence pointer "%s" and none reached predicate:green\n' "$nums" "$ev"
|
|
2312
|
+
printf ' a loop making no progress stops and writes up why; it does not spend its budget thrashing\n'
|
|
2313
|
+
return 1
|
|
2314
|
+
fi
|
|
2315
|
+
done < <(loop_decision_rows)
|
|
2316
|
+
printf 'no three consecutive verdict rows share an evidence pointer without reaching predicate:green\n'
|
|
2317
|
+
return 0
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
check_lp_05() {
|
|
2321
|
+
if ! loop_present; then loop_no_record; return 3; fi
|
|
2322
|
+
[ -f "$LOOP_DIR/decisions.tsv" ] || { printf 'no decisions.tsv to read\n'; return 1; }
|
|
2323
|
+
local last rn res s n
|
|
2324
|
+
last=$(awk -F'\t' 'NR > 1 && NF >= 6 && $3 ~ /^verdict:/ { rn = NR; res = $6 } END { if (rn) print rn "\t" res }' "$LOOP_DIR/decisions.tsv")
|
|
2325
|
+
if [ -z "$last" ]; then
|
|
2326
|
+
printf 'the record holds no verdict row - there is no closing state to audit\n'
|
|
2327
|
+
return 3
|
|
2328
|
+
fi
|
|
2329
|
+
rn=${last%%$'\t'*}
|
|
2330
|
+
res=${last#*$'\t'}
|
|
2331
|
+
if [ "$res" = "predicate:green" ]; then
|
|
2332
|
+
printf 'the last verdict row (row %s) reached predicate:green - no write-up required\n' "$rn"
|
|
2333
|
+
return 0
|
|
2334
|
+
fi
|
|
2335
|
+
s="$LOOP_DIR/stuck.md"
|
|
2336
|
+
[ -f "$s" ] || { printf 'the loop ended at "%s" (row %s) and left no .goblin/loop/stuck.md\n' "${res:-<empty>}" "$rn"; return 1; }
|
|
2337
|
+
n=$(grep -c '[^[:space:]]' "$s")
|
|
2338
|
+
if [ "$n" -lt 3 ]; then
|
|
2339
|
+
printf 'stuck.md holds %s non-blank line(s); a write-up needs at least 3\n' "$n"
|
|
2340
|
+
return 1
|
|
2341
|
+
fi
|
|
2342
|
+
grep -q 'predicate' "$s" || { printf 'stuck.md does not name the predicate it failed to satisfy\n'; return 1; }
|
|
2343
|
+
printf 'the loop ended at "%s" (row %s) and stuck.md names the predicate in %s line(s)\n' "$res" "$rn" "$n"
|
|
2344
|
+
return 0
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
# ---- the reference corpus (P15): RC-01..RC-04 ------------------------------------------------
|
|
2348
|
+
# P15's machine half. All four read DECLARED values - `reference_manifest:` and the
|
|
2349
|
+
# `security: build_output:` block SC-03 clause 2 already reads - and never infer a path from the
|
|
2350
|
+
# stack. They are builtins because a shell one-liner cannot SKIP: the runner turns any non-zero
|
|
2351
|
+
# shell exit into a FAIL, so a repo with no corpus would be born RED (see the dispatch below).
|
|
2352
|
+
|
|
2353
|
+
rc_path() { # the declared manifest, as an absolute path
|
|
2354
|
+
case "$REFERENCE_MANIFEST" in
|
|
2355
|
+
/*) printf '%s' "$REFERENCE_MANIFEST" ;;
|
|
2356
|
+
*) printf '%s' "$ROOT/$REFERENCE_MANIFEST" ;;
|
|
2357
|
+
esac
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
# rc_parse <mode> <manifest> — mode `entries` prints one "sha256<TAB>path" line per entry; mode
|
|
2361
|
+
# `validate` prints one line per schema violation. Both exit 1 when the file cannot be read or is
|
|
2362
|
+
# not the shape they expect, and both print to stdout so the caller can show the reason.
|
|
2363
|
+
rc_parse() {
|
|
2364
|
+
python3 -c '
|
|
2365
|
+
import json, re, sys
|
|
2366
|
+
mode, path = sys.argv[1], sys.argv[2]
|
|
2367
|
+
hex64 = re.compile(r"^[0-9a-fA-F]{64}$")
|
|
2368
|
+
try:
|
|
2369
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
2370
|
+
doc = json.load(fh)
|
|
2371
|
+
except Exception as exc:
|
|
2372
|
+
sys.stdout.write("unparseable JSON: %s\n" % exc)
|
|
2373
|
+
sys.exit(1)
|
|
2374
|
+
if not isinstance(doc, dict):
|
|
2375
|
+
sys.stdout.write("the manifest is not a JSON object\n")
|
|
2376
|
+
sys.exit(1)
|
|
2377
|
+
entries = doc.get("entries")
|
|
2378
|
+
if mode == "entries":
|
|
2379
|
+
if not isinstance(entries, list):
|
|
2380
|
+
sys.stdout.write("entries is not a list\n")
|
|
2381
|
+
sys.exit(1)
|
|
2382
|
+
for e in entries:
|
|
2383
|
+
if isinstance(e, dict):
|
|
2384
|
+
sys.stdout.write("%s\t%s\n" % (e.get("sha256", ""), e.get("path", "")))
|
|
2385
|
+
sys.exit(0)
|
|
2386
|
+
bad = []
|
|
2387
|
+
def want(cond, msg):
|
|
2388
|
+
if not cond:
|
|
2389
|
+
bad.append(msg)
|
|
2390
|
+
want(doc.get("schema") == "reference-manifest/1", "schema is not reference-manifest/1")
|
|
2391
|
+
gf = doc.get("generated_from")
|
|
2392
|
+
want(isinstance(gf, str) and gf.strip() != "", "generated_from is empty")
|
|
2393
|
+
app = doc.get("reference_app")
|
|
2394
|
+
want(isinstance(app, dict), "reference_app is missing")
|
|
2395
|
+
if isinstance(app, dict):
|
|
2396
|
+
for key in ("package", "version"):
|
|
2397
|
+
v = app.get(key)
|
|
2398
|
+
want(isinstance(v, str) and v.strip() != "", "reference_app.%s is empty" % key)
|
|
2399
|
+
want(bool(hex64.match(str(app.get("apk_sha256", "")))), "reference_app.apk_sha256 is not 64 hex")
|
|
2400
|
+
want(isinstance(entries, list) and len(entries) > 0, "entries is empty")
|
|
2401
|
+
if isinstance(entries, list):
|
|
2402
|
+
for i, e in enumerate(entries):
|
|
2403
|
+
if not isinstance(e, dict):
|
|
2404
|
+
bad.append("entry %d is not an object" % i)
|
|
2405
|
+
continue
|
|
2406
|
+
p = e.get("path")
|
|
2407
|
+
want(isinstance(p, str) and p.strip() != "", "entry %d: path is empty" % i)
|
|
2408
|
+
want(bool(hex64.match(str(e.get("sha256", "")))), "entry %d: sha256 is not 64 hex" % i)
|
|
2409
|
+
want(isinstance(e.get("bytes"), int) and not isinstance(e.get("bytes"), bool),
|
|
2410
|
+
"entry %d: bytes is not a number" % i)
|
|
2411
|
+
ec = doc.get("entry_count")
|
|
2412
|
+
want(isinstance(ec, int) and not isinstance(ec, bool) and ec == len(entries),
|
|
2413
|
+
"entry_count does not equal len(entries)")
|
|
2414
|
+
for m in bad:
|
|
2415
|
+
sys.stdout.write("%s\n" % m)
|
|
2416
|
+
sys.exit(1 if bad else 0)
|
|
2417
|
+
' "$1" "$2"
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
# rc_build_output_files — every regular file under each declared build-output dir. The key is
|
|
2421
|
+
# space-separated and a directory that does not exist is skipped, exactly as SC-03 clause 2 does.
|
|
2422
|
+
rc_build_output_files() {
|
|
2423
|
+
local d
|
|
2424
|
+
for d in $SEC_BUILD_OUTPUT; do
|
|
2425
|
+
[ -d "$ROOT/$d" ] || continue
|
|
2426
|
+
find "$ROOT/$d" -type f 2>/dev/null
|
|
2427
|
+
done
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
# rc_in_build_output <abs path> — is that path inside one of the declared build-output dirs?
|
|
2431
|
+
rc_in_build_output() {
|
|
2432
|
+
local p="$1" d
|
|
2433
|
+
for d in $SEC_BUILD_OUTPUT; do
|
|
2434
|
+
case "$p" in "$ROOT/$d"/*) return 0 ;; esac
|
|
2435
|
+
done
|
|
2436
|
+
return 1
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
check_rc_01() {
|
|
2440
|
+
# Four clauses. (1) `reference_manifest:` empty is the ONE skip, and it carries its reason.
|
|
2441
|
+
# (2) declared but missing or unparseable is a FAIL - losing the manifest must not read as "no
|
|
2442
|
+
# corpus". (3) any file under the declared build output whose sha256 appears in an entry's
|
|
2443
|
+
# sha256 fails, naming the path and the entry. (4) the manifest itself is not shippable.
|
|
2444
|
+
if [ -z "$REFERENCE_MANIFEST" ]; then
|
|
2445
|
+
printf 'reference_manifest: is empty - no reference corpus is declared, so there is nothing for a build to match against\n'
|
|
2446
|
+
return 3
|
|
2447
|
+
fi
|
|
2448
|
+
local mf; mf=$(rc_path)
|
|
2449
|
+
if [ ! -f "$mf" ]; then
|
|
2450
|
+
printf 'reference_manifest: %s does not exist - a declared corpus that is absent is a hole, not a skip\n' "$REFERENCE_MANIFEST"
|
|
2451
|
+
return 1
|
|
2452
|
+
fi
|
|
2453
|
+
local entries
|
|
2454
|
+
if ! entries=$(rc_parse entries "$mf"); then
|
|
2455
|
+
printf 'reference_manifest: %s is not parseable - a declared corpus that cannot be read is a FAIL\n' "$REFERENCE_MANIFEST"
|
|
2456
|
+
printf '%s\n' "$entries" | sed 's/^/ /'
|
|
2457
|
+
return 1
|
|
2458
|
+
fi
|
|
2459
|
+
local n_entries; n_entries=$(printf '%s\n' "$entries" | grep -c . || true)
|
|
2460
|
+
local bad=0 out_n=0 f h hit mh
|
|
2461
|
+
mh=$(g_sha256_file "$mf")
|
|
2462
|
+
# clause 4, both halves of "the manifest is not itself shippable": the declared path must not
|
|
2463
|
+
# live inside the build output, and no COPY of the manifest may sit there either - the manifest
|
|
2464
|
+
# is a listing of every corpus hash, so shipping it ships the corpus's shape.
|
|
2465
|
+
if rc_in_build_output "$mf"; then
|
|
2466
|
+
printf 'the reference manifest itself sits inside the declared build output: %s\n' "${mf#"$ROOT"/}"
|
|
2467
|
+
bad=1
|
|
2468
|
+
fi
|
|
2469
|
+
while IFS= read -r f; do
|
|
2470
|
+
[ -n "$f" ] || continue
|
|
2471
|
+
out_n=$((out_n + 1))
|
|
2472
|
+
h=$(g_sha256_file "$f")
|
|
2473
|
+
if [ "$h" = "$mh" ]; then
|
|
2474
|
+
printf 'a copy of the reference manifest is in the build output: %s\n' "${f#"$ROOT"/}"
|
|
2475
|
+
bad=1
|
|
2476
|
+
continue
|
|
2477
|
+
fi
|
|
2478
|
+
hit=$(printf '%s\n' "$entries" | awk -F'\t' -v h="$h" '$1 == h { print $2; exit }')
|
|
2479
|
+
if [ -n "$hit" ]; then
|
|
2480
|
+
printf 'a reference-corpus payload is in the build output: %s (entry %s)\n' "${f#"$ROOT"/}" "$hit"
|
|
2481
|
+
bad=1
|
|
2482
|
+
fi
|
|
2483
|
+
done < <(rc_build_output_files)
|
|
2484
|
+
[ "$bad" -eq 0 ] || return 1
|
|
2485
|
+
if [ "$out_n" -eq 0 ]; then
|
|
2486
|
+
printf '%s entry/entries; no build output to scan (security: build_output: names no directory that exists)\n' "$n_entries"
|
|
2487
|
+
else
|
|
2488
|
+
printf '%s entry/entries; %s build-output file(s) hashed, none matches\n' "$n_entries" "$out_n"
|
|
2489
|
+
fi
|
|
2490
|
+
return 0
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
check_rc_02() {
|
|
2494
|
+
# The vacuous-pass guard: RC-01 cannot pass on a manifest that was authored weak. SKIPs with
|
|
2495
|
+
# RC-01's reason when the key is empty; a declared manifest that is absent or ill-shaped is a
|
|
2496
|
+
# FAIL, never a skip.
|
|
2497
|
+
if [ -z "$REFERENCE_MANIFEST" ]; then
|
|
2498
|
+
printf 'reference_manifest: is empty - there is no manifest to shape-check (RC-01 skips for the same reason)\n'
|
|
2499
|
+
return 3
|
|
2500
|
+
fi
|
|
2501
|
+
local mf; mf=$(rc_path)
|
|
2502
|
+
if [ ! -f "$mf" ]; then
|
|
2503
|
+
printf 'reference_manifest: %s does not exist - a declared manifest that is absent is a hole, not a skip\n' "$REFERENCE_MANIFEST"
|
|
2504
|
+
return 1
|
|
2505
|
+
fi
|
|
2506
|
+
local errs=""
|
|
2507
|
+
errs=$(rc_parse validate "$mf") && {
|
|
2508
|
+
printf '%s is shaped so RC-01 cannot pass vacuously (schema, provenance, non-empty entries, entry_count)\n' "$REFERENCE_MANIFEST"
|
|
2509
|
+
return 0
|
|
2510
|
+
}
|
|
2511
|
+
printf '%s is not a usable reference manifest:\n' "$REFERENCE_MANIFEST"
|
|
2512
|
+
printf '%s\n' "$errs" | sed 's/^/ /'
|
|
2513
|
+
return 1
|
|
2514
|
+
}
|
|
2515
|
+
|
|
2516
|
+
check_rc_03() {
|
|
2517
|
+
# Two clauses over the LAB repo: every tracked path is inside the declared allowlist, and no
|
|
2518
|
+
# tracked file's sha256 equals a hash in a manifests/*.sha256. The manifest lists paths and
|
|
2519
|
+
# hashes, which the lab's own README permits - the check is about BYTES, so the manifest is
|
|
2520
|
+
# never read as a violation (its rows name files under the quarantine anchor, outside the repo).
|
|
2521
|
+
if [ ! -d "$ROOT/manifests" ]; then
|
|
2522
|
+
printf 'no manifests/ directory - this is not a lab repo with a reference corpus, so there are no tracked bytes to check\n'
|
|
2523
|
+
return 3
|
|
2524
|
+
fi
|
|
2525
|
+
local mfs; mfs=$(ls "$ROOT"/manifests/*.sha256 2>/dev/null || true)
|
|
2526
|
+
if [ -z "$mfs" ]; then
|
|
2527
|
+
printf 'manifests/ exists but holds no *.sha256 - a corpus manifest directory that cannot be read is a FAIL, not a skip\n'
|
|
2528
|
+
return 1
|
|
2529
|
+
fi
|
|
2530
|
+
local bad=0 p h m hashes="" n=0
|
|
2531
|
+
for m in $mfs; do
|
|
2532
|
+
hashes="$hashes$(awk '!/^#/ && NF >= 2 { print $1 }' "$m")"$'\n'
|
|
2533
|
+
done
|
|
2534
|
+
while IFS= read -r p; do
|
|
2535
|
+
[ -n "$p" ] || continue
|
|
2536
|
+
n=$((n + 1))
|
|
2537
|
+
case "$p" in
|
|
2538
|
+
scripts/*|notes/*|manifests/*) continue ;;
|
|
2539
|
+
# The harness's own directories and the .gitignore block it appends are the install, not the
|
|
2540
|
+
# lab's content - the same exclusion FM-02 makes for the same reason (W5-4). That set is the
|
|
2541
|
+
# one the class-A install contract creates: .goblin/, .hermes/, the .gitignore block, the
|
|
2542
|
+
# declared harness_dir (checks/ by default), reviews/, and the CI scaffold .github/. A payload
|
|
2543
|
+
# hidden in any of them is still caught by clause 2 (the sha256 sweep below).
|
|
2544
|
+
.goblin/*|.hermes/*|.gitignore|.github/*|reviews/*) continue ;;
|
|
2545
|
+
"${GOBLIN_HARNESS_REL:-checks}"/*) continue ;;
|
|
2546
|
+
esac
|
|
2547
|
+
case "$p" in
|
|
2548
|
+
*/*) bad=1; printf 'a tracked path outside the lab allowlist (scripts/, notes/, manifests/, root docs): %s\n' "$p" ;;
|
|
2549
|
+
*.md) ;; # a root-level doc
|
|
2550
|
+
*) bad=1; printf 'a tracked path outside the lab allowlist (scripts/, notes/, manifests/, root docs): %s\n' "$p" ;;
|
|
2551
|
+
esac
|
|
2552
|
+
done < <( cd "$ROOT" && git ls-files )
|
|
2553
|
+
while IFS= read -r p; do
|
|
2554
|
+
[ -n "$p" ] || continue
|
|
2555
|
+
h=$(g_sha256_file "$ROOT/$p")
|
|
2556
|
+
if printf '%s' "$hashes" | grep -qxF "$h"; then
|
|
2557
|
+
printf 'a reference-corpus payload is TRACKED in the lab repo: %s\n' "$p"
|
|
2558
|
+
bad=1
|
|
2559
|
+
fi
|
|
2560
|
+
done < <( cd "$ROOT" && git ls-files )
|
|
2561
|
+
[ "$bad" -eq 0 ] || return 1
|
|
2562
|
+
printf '%s tracked file(s): all inside the lab allowlist, none matching a corpus hash\n' "$n"
|
|
2563
|
+
return 0
|
|
2564
|
+
}
|
|
2565
|
+
|
|
2566
|
+
check_rc_04() {
|
|
2567
|
+
# The acquisition record, and the weakest of the four: it proves a record EXISTS, never that
|
|
2568
|
+
# the number beside it came from the store. SKIPs when no manifest is declared; a manifest with
|
|
2569
|
+
# no header block is a FAIL.
|
|
2570
|
+
local mfs; mfs=$(ls "$ROOT"/manifests/*.sha256 2>/dev/null || true)
|
|
2571
|
+
if [ -z "$mfs" ]; then
|
|
2572
|
+
printf 'no manifests/*.sha256 - no acquisition record is declared, so there is none to read\n'
|
|
2573
|
+
return 3
|
|
2574
|
+
fi
|
|
2575
|
+
local bad=0 m hdr tgt src c64 apk apkh n=0
|
|
2576
|
+
for m in $mfs; do
|
|
2577
|
+
n=$((n + 1))
|
|
2578
|
+
hdr=$(awk '/^#/ { print; next } { exit }' "$m")
|
|
2579
|
+
if [ -z "$hdr" ]; then
|
|
2580
|
+
printf '%s: no header block - the acquisition record is absent\n' "${m#"$ROOT"/}"
|
|
2581
|
+
bad=1; continue
|
|
2582
|
+
fi
|
|
2583
|
+
tgt=$(rc_hdr_field "$hdr" target)
|
|
2584
|
+
src=$(rc_hdr_field "$hdr" source)
|
|
2585
|
+
c64=$(printf '%s\n' "$hdr" | grep -oiE '[0-9a-f]{64}' | head -n 1)
|
|
2586
|
+
# the header's own apk path
|
|
2587
|
+
apk=$(printf '%s' "$tgt" | grep -oiE '[A-Za-z0-9._-]+\.apk' | head -n 1)
|
|
2588
|
+
apkh=$(awk '!/^#/ && NF >= 2 { print $1 "\t" $2 }' "$m" | awk -F'\t' 'tolower($2) ~ /\.apk$/ { print; exit }')
|
|
2589
|
+
if [ -z "$tgt" ] || ! printf '%s' "$tgt" | grep -qE '[0-9]+\.[0-9]+'; then
|
|
2590
|
+
printf '%s: the header names no target slug + version (target: <file> <version>)\n' "${m#"$ROOT"/}"; bad=1
|
|
2591
|
+
fi
|
|
2592
|
+
if [ -z "$src" ] || ! printf '%s' "$src" | grep -qE '[A-Za-z0-9.-]+\.[A-Za-z]{2,}'; then
|
|
2593
|
+
printf '%s: the header names no source store (source: <store> [; <checksum>])\n' "${m#"$ROOT"/}"; bad=1
|
|
2594
|
+
fi
|
|
2595
|
+
if ! printf '%s\n' "$hdr" | grep -qoiE '[0-9a-f]{32}([^0-9a-f]|$)'; then
|
|
2596
|
+
printf '%s: the header carries no checksum (md5 or sha256 hex)\n' "${m#"$ROOT"/}"; bad=1
|
|
2597
|
+
fi
|
|
2598
|
+
if [ -z "$apkh" ]; then
|
|
2599
|
+
printf '%s: the manifest holds no *.apk row\n' "${m#"$ROOT"/}"; bad=1
|
|
2600
|
+
elif [ -n "$apk" ] && [ "${apkh#*$'\t'}" != "" ]; then
|
|
2601
|
+
local rowbase; rowbase=${apkh#*$'\t'}; rowbase=${rowbase##*/}
|
|
2602
|
+
if [ "$apk" != "$rowbase" ]; then
|
|
2603
|
+
printf '%s: the header names %s but the manifest rows %s\n' "${m#"$ROOT"/}" "$apk" "$rowbase"; bad=1
|
|
2604
|
+
fi
|
|
2605
|
+
fi
|
|
2606
|
+
if [ -n "$c64" ] && [ -n "$apkh" ]; then
|
|
2607
|
+
local rowhash; rowhash=${apkh%%$'\t'*}
|
|
2608
|
+
[ "$c64" = "$rowhash" ] || { printf '%s: the header sha256 %s disagrees with the apk row %s\n' "${m#"$ROOT"/}" "$c64" "$rowhash"; bad=1; }
|
|
2609
|
+
fi
|
|
2610
|
+
done
|
|
2611
|
+
[ "$bad" -eq 0 ] || return 1
|
|
2612
|
+
printf '%s acquisition record(s): target + version, source store, checksum, apk row\n' "$n"
|
|
2613
|
+
return 0
|
|
2614
|
+
}
|
|
2615
|
+
|
|
2616
|
+
# rc_hdr_field <header text> <key> — the value of a `# key : value` header line, case-insensitive.
|
|
2617
|
+
rc_hdr_field() {
|
|
2618
|
+
printf '%s\n' "$1" | awk -v k="$2" '
|
|
2619
|
+
/^#[[:space:]]*[A-Za-z_]+[[:space:]]*:/ {
|
|
2620
|
+
line = $0; sub(/^#[[:space:]]*/, "", line)
|
|
2621
|
+
key = line; sub(/:.*/, "", key); gsub(/[[:space:]]/, "", key)
|
|
2622
|
+
if (tolower(key) == tolower(k)) { v = line; sub(/^[^:]*:[[:space:]]*/, "", v); print v; exit }
|
|
2623
|
+
}
|
|
2624
|
+
'
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
# ================================================================ runner ======
|
|
2628
|
+
|
|
2629
|
+
PASS=0; FAIL=0; ADV=0; SKIP=0; BAD=0
|
|
2630
|
+
JSON_ROWS=""
|
|
2631
|
+
|
|
2632
|
+
# json_escape <text> — a JSON string body. The detail of a check is arbitrary shell output
|
|
2633
|
+
# (quotes, backslashes, newlines), so it must be escaped or --json emits invalid JSON (D3).
|
|
2634
|
+
json_escape() {
|
|
2635
|
+
local s="$1"
|
|
2636
|
+
s=${s//\\/\\\\}
|
|
2637
|
+
s=${s//\"/\\\"}
|
|
2638
|
+
s=${s//$'\n'/\\n}
|
|
2639
|
+
s=${s//$'\r'/\\r}
|
|
2640
|
+
s=${s//$'\t'/\\t}
|
|
2641
|
+
printf '%s' "$s"
|
|
2642
|
+
}
|
|
2643
|
+
|
|
2644
|
+
emit() { # id status detail
|
|
2645
|
+
if [ "$JSON" -eq 1 ]; then
|
|
2646
|
+
JSON_ROWS="$JSON_ROWS{\"id\":\"$(json_escape "$1")\",\"status\":\"$(json_escape "$2")\",\"detail\":\"$(json_escape "$3")\"},"
|
|
2647
|
+
else
|
|
2648
|
+
case "$2" in
|
|
2649
|
+
PASS) g_pass "$1" "$3" ;;
|
|
2650
|
+
FAIL) g_fail "$1" "$3" ;;
|
|
2651
|
+
ADV) g_adv "$1" "$3" ;;
|
|
2652
|
+
SKIP) g_skip "$1" "$3" ;;
|
|
2653
|
+
esac
|
|
2654
|
+
fi
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
while IFS=$'\t' read -r id scope rule enforced_by artifact check why; do
|
|
2658
|
+
[ "$id" = "id" ] && continue
|
|
2659
|
+
[ -n "$id" ] || continue
|
|
2660
|
+
[ "$scope" = "target" ] || continue
|
|
2661
|
+
only_selected "$id" || continue
|
|
2662
|
+
|
|
2663
|
+
part=$(row_part "$id")
|
|
2664
|
+
if [ -n "$part" ] && g_part_disabled "$CONFIG" "$part"; then
|
|
2665
|
+
SKIP=$((SKIP + 1)); emit "$id" SKIP "$rule (opt-out: $part)"; continue
|
|
2666
|
+
fi
|
|
2667
|
+
if [ "$ARCHIVE" = "true" ]; then
|
|
2668
|
+
case "$id" in
|
|
2669
|
+
HP-*|GT-*|SP-*) SKIP=$((SKIP + 1)); emit "$id" SKIP "$rule (archive: true)"; continue ;;
|
|
2670
|
+
esac
|
|
2671
|
+
fi
|
|
2672
|
+
|
|
2673
|
+
case "$check" in
|
|
2674
|
+
advisory)
|
|
2675
|
+
ADV=$((ADV + 1))
|
|
2676
|
+
emit "$id" ADV "$rule (advisory)"
|
|
2677
|
+
continue
|
|
2678
|
+
;;
|
|
2679
|
+
goblin-verify\ --only\ *)
|
|
2680
|
+
fn="check_$(printf '%s' "$id" | tr 'A-Z' 'a-z' | tr '-' '_')"
|
|
2681
|
+
if ! command -v "$fn" >/dev/null 2>&1 && ! declare -F "$fn" >/dev/null 2>&1; then
|
|
2682
|
+
BAD=1; emit "$id" FAIL "$rule (no builtin implementation: $fn)"; continue
|
|
2683
|
+
fi
|
|
2684
|
+
detail=$("$fn" 2>&1); rc=$?
|
|
2685
|
+
;;
|
|
2686
|
+
*)
|
|
2687
|
+
detail=$( cd "$ROOT" && bash -c "$check" 2>&1 ); rc=$?
|
|
2688
|
+
# A shell check has no advisory state: anything non-zero is a FAIL. Only builtins
|
|
2689
|
+
# (the 'goblin-verify --only <ID>' marker rows) may return 2 = advisory or 3 = skip.
|
|
2690
|
+
if [ "$rc" -eq 0 ]; then
|
|
2691
|
+
[ -n "$detail" ] || detail="($check)"
|
|
2692
|
+
else
|
|
2693
|
+
detail="($check -> exit $rc)"
|
|
2694
|
+
rc=1
|
|
2695
|
+
fi
|
|
2696
|
+
;;
|
|
2697
|
+
esac
|
|
2698
|
+
|
|
2699
|
+
case "$rc" in
|
|
2700
|
+
0) PASS=$((PASS + 1)); emit "$id" PASS "$detail" ;;
|
|
2701
|
+
1) FAIL=$((FAIL + 1)); emit "$id" FAIL "$detail" ;;
|
|
2702
|
+
2) ADV=$((ADV + 1)); emit "$id" ADV "$detail" ;;
|
|
2703
|
+
3) SKIP=$((SKIP + 1)); emit "$id" SKIP "$detail" ;;
|
|
2704
|
+
*) FAIL=$((FAIL + 1)); emit "$id" FAIL "$detail (exit $rc)" ;;
|
|
2705
|
+
esac
|
|
2706
|
+
done < "$MANIFEST"
|
|
2707
|
+
|
|
2708
|
+
if [ "$JSON" -eq 1 ]; then
|
|
2709
|
+
printf '{"root":"%s","class":"%s","passed":%d,"failed":%d,"advisory":%d,"skipped":%d,"rows":[%s]}\n' \
|
|
2710
|
+
"$(json_escape "$ROOT")" "$(json_escape "$CLASS")" "$PASS" "$FAIL" "$ADV" "$SKIP" "${JSON_ROWS%,}"
|
|
2711
|
+
else
|
|
2712
|
+
printf ' %d passed, %d failed, %d advisory, %d skipped\n' "$PASS" "$FAIL" "$ADV" "$SKIP"
|
|
2713
|
+
# Z1-7: the advisory number above counts TWO different things - the rows the matrix LABELS
|
|
2714
|
+
# advisory, and a row that is not advisory-labelled but whose lane cannot be resolved here
|
|
2715
|
+
# (JG-02's unresolved judge lane returns 2 = ADV by design). SK-03 counts the first, the ADV
|
|
2716
|
+
# lines on screen are both, so a reader comparing `advisory 10 of ceiling 10` with 11 ADV lines
|
|
2717
|
+
# saw a mismatch that only the row's why-cell explained. Print the arithmetic.
|
|
2718
|
+
adv_labelled=$(awk -F'\t' 'NR>1 && ($4=="advisory" || $6=="advisory") {n++} END{print n+0}' "$MANIFEST")
|
|
2719
|
+
if [ "$ADV" -ne "$adv_labelled" ]; then
|
|
2720
|
+
printf ' of those %d advisory: the matrix labels %d row(s) advisory; the difference is the non-advisory row(s) reporting ADV by design (an unresolved lane) and any labelled row skipped by an opt-out\n' \
|
|
2721
|
+
"$ADV" "$adv_labelled"
|
|
2722
|
+
fi
|
|
2723
|
+
cat <<'SEE'
|
|
2724
|
+
cannot see: whether a check in the harness dir tests the right path rather than
|
|
2725
|
+
merely passing; whether the forge is bound by the workflow CL-01 found; whether a human
|
|
2726
|
+
read the diff;
|
|
2727
|
+
whether the model mapping names a family that actually differs (MD-02 reports it
|
|
2728
|
+
and cannot change it); and whether .goblin/installed.json — the record every drift
|
|
2729
|
+
check trusts — was itself rewritten. It is not signed, so one edit to it (with the
|
|
2730
|
+
matching edit to the file it protects) defeats IN-02, SK-02 and HS-01 at once
|
|
2731
|
+
(docs/LIMITS.md #18). The ban lane carries the same blind spot and two of its own:
|
|
2732
|
+
the ban table is a manifest file under the same unsigned record, so a defanged ban
|
|
2733
|
+
passes BN-00 (docs/LIMITS.md #28); a ban probe is a TEXT probe with no AST, so a
|
|
2734
|
+
`Record<string, any>` is missed and a `: any` inside a string is reported
|
|
2735
|
+
(docs/LIMITS.md #27); and no check here can reach a ban IN CONTEXT — the row proves
|
|
2736
|
+
the forbidden path fails mechanically, never that the ban reached the agent that
|
|
2737
|
+
wrote the line. The judge/loop lane adds its own: every JG-/LP- row reads a RECORD, so
|
|
2738
|
+
it proves a handle, a pin, a budget and a pointer exist — never that the loop made
|
|
2739
|
+
progress (a changed pointer is a proxy, docs/LIMITS.md #33), never which lane returned
|
|
2740
|
+
a verdict, and never that the predicate was the right one or was really run. The CI lane
|
|
2741
|
+
adds its own: PG-05 and PG-06 read a workflow's TEXT, so PG-05 sees a job whose gate step is
|
|
2742
|
+
conditional and cannot see branch protection, the required-check list, or a forge that never
|
|
2743
|
+
ran the job at all, and PG-06 proves a declared gate is INVOKED in a file under
|
|
2744
|
+
.github/workflows/ — never that the forge marks that job required, never that it is the job
|
|
2745
|
+
the forge waits on, and never that the workflow can fail (that is PG-05). A required check
|
|
2746
|
+
that reports Success after skipping its gate is a green light for a commit whose gate never
|
|
2747
|
+
ran (docs/CI.md). The reference-corpus lane adds its own: RC-01 matches EXACT sha256 only,
|
|
2748
|
+
so a resized, recoloured or re-encoded reference asset, or copied text inside a shipped
|
|
2749
|
+
string, passes it unseen; RC-02 proves the manifest is well-shaped, never that its entries
|
|
2750
|
+
are the right hashes (as strong as tamper hashing, and installed.json is unsigned); RC-03
|
|
2751
|
+
reads the tracked tree and cannot see a payload renamed and re-encoded; and RC-04 proves an
|
|
2752
|
+
acquisition record EXISTS, never that the number beside it came from the store (HP-03's
|
|
2753
|
+
defect, one artifact over). No row here reaches a reference asset in CONTEXT either.
|
|
2754
|
+
SEE
|
|
2755
|
+
# ---- the engine footer (W1 §2.2, R-1's residue -> docs/LIMITS.md #43) --------
|
|
2756
|
+
# Every run states WHICH engine judged it: the sha256 of the running verifier and of
|
|
2757
|
+
# the manifest that judged this run, so a repo can name its judge. The statement is
|
|
2758
|
+
# UNSIGNED and forgeable - one forgery now covers N repos - which is the hole LIMITS
|
|
2759
|
+
# #43 records, deliberately not fixed in W1 (G6).
|
|
2760
|
+
# W6: the banner goes to STDERR, not stdout (LIM-51). A consumer capturing verify's
|
|
2761
|
+
# output parses check lines; a footer line interleaved with them is parser friction
|
|
2762
|
+
# (docs/LIMITS.md #49 recorded the cost before the fix). The banner is a STATEMENT,
|
|
2763
|
+
# not a verdict line - diagnostics belong on the error stream. Captured stdout after
|
|
2764
|
+
# the move carries PASS/FAIL/ADV/SKIP lines and the summary only; the banner stays
|
|
2765
|
+
# visible in a terminal and in any `2>&1` merge (every suite that asserts the
|
|
2766
|
+
# footer merges the streams).
|
|
2767
|
+
printf ' engine: mode=%s' "$ENGINE_MODE" >&2
|
|
2768
|
+
if [ -n "$GOBLIN_ENGINE" ]; then printf ' engine_dir=%s' "$GOBLIN_ENGINE" >&2; fi
|
|
2769
|
+
printf ' cli_sha256=%s enforcement_tsv_sha256=%s\n' \
|
|
2770
|
+
"$(g_sha256_file "$SELF_DIR/goblin-verify")" \
|
|
2771
|
+
"$(g_sha256_file "$MANIFEST")" >&2
|
|
2772
|
+
# ---- the shadow advisory (W3 §3.2) -------------------------------------------
|
|
2773
|
+
# A vendored payload under .goblin/ while the record says mode=global: the chain's
|
|
2774
|
+
# vendored step (2) beats the declared engine_dir (3) — W1's ordering, kept
|
|
2775
|
+
# deliberately — so a STALE vendored table judged this run while the footer above
|
|
2776
|
+
# still prints mode=global. This line names that split. It is deliberately a footer
|
|
2777
|
+
# ADVISORY, not a manifest row: the advisory ceiling is `advisory 10 of ceiling 10`
|
|
2778
|
+
# on every migrated repo (SK-03 measured at W3-SPEC MA5, 0 free slots), so a new row
|
|
2779
|
+
# would fail every migrated repo — and adding a row is a G2 rule change W3 has no
|
|
2780
|
+
# mandate for. Detector, not prevention: the residue is LIMITS #45.
|
|
2781
|
+
if [ "$ENGINE_MODE" = "global" ]; then
|
|
2782
|
+
_shadow_n=0
|
|
2783
|
+
for _f in "$ROOT"/.goblin/bin/goblin-verify "$ROOT"/.goblin/bin/goblin-lib.sh \
|
|
2784
|
+
"$ROOT"/.goblin/bin/goblin-audit "$ROOT"/.goblin/bin/goblin-bans \
|
|
2785
|
+
"$ROOT"/.goblin/manifest/* "$ROOT"/.goblin/bans/* \
|
|
2786
|
+
"$ROOT"/.goblin/automations/* "$ROOT"/.goblin/roles.yaml; do
|
|
2787
|
+
[ -e "$_f" ] || continue
|
|
2788
|
+
_shadow_n=$((_shadow_n + 1))
|
|
2789
|
+
done
|
|
2790
|
+
if [ "$_shadow_n" -gt 0 ]; then
|
|
2791
|
+
printf ' shadow: vendored engine payload present (%d file(s)) - the vendored table judged this run, not the declared engine_dir\n' "$_shadow_n"
|
|
2792
|
+
fi
|
|
2793
|
+
unset _shadow_n _f
|
|
2794
|
+
fi
|
|
2795
|
+
fi
|
|
2796
|
+
|
|
2797
|
+
if [ "$FAIL" -gt 0 ] || [ "$BAD" -eq 1 ]; then exit 1; fi
|
|
2798
|
+
exit 0
|