Compare commits

...

12 Commits

Author SHA1 Message Date
shanglei
217f4e5567 fix(qualitygate): pin the examples surface with an allowlist
examples-surface-only promised that demos may consume only the assembled CLI
and the public plugin SDK, but it enforced two denied prefixes instead, so
every tree nobody thought to deny was permitted. A demo importing `events`,
`errs` or a `cmd` subpackage passed the gate, and because
extension-zero-internal exempts these packages from the transitive check,
nothing examined what those imports dragged in either. The exemption was
therefore unbounded in what it covered, the same defect as the directory-name
skip it replaced.

- Add Rule.AllowedRepoDeps, which inverts the check: any dependency inside
  this module that is not listed is a violation. Standard library and
  third-party packages, including same-organisation modules that are not this
  one, stay outside the rule.
- Pin examples-surface-only to exactly `cmd` and `extension/platform`, so the
  rule name matches what it enforces and the inherited chain stays bounded by
  a direct surface of two packages.
- Cover the reproducers as contract cases: other repository trees, `cmd`
  subpackages, other `extension` subtrees, and the module root are rejected,
  while the two allowed packages plus non-module imports are not.

layering-edges.txt stays at 39 rows; the demos already import only the two
allowed packages.
2026-07-25 14:30:29 +08:00
shanglei
820305536c fix(qualitygate): scope the examples exemption to wrapper mains
The extension rule skipped any package whose import path contained
"/examples/", which let the gate miss two things: a directory named
examples anywhere under extension escaped the rule outright, and the
sanctioned demos were exempt from every denial rather than only from the
internal packages they inherit through cmd.

- Drop SkipFrom (and containsAny) so no rule can exempt by directory name.
- Exempt the two wrapper-main demos from extension-zero-internal by exact
  import path. Their cmd import is the pattern they exist to demonstrate,
  and seeding those edges instead would wedge the ratchet: the edges track
  cmd's transitive set, so a new internal package under cmd would demand a
  new row that check-layering-ratchet.sh refuses by design.
- Add examples-surface-only: demos may consume cmd and extension/platform
  but must not directly import internal or shortcuts. Zero violations today.

layering-edges.txt stays at 39 rows, so the ratchet bootstrap snapshot
still matches.
2026-07-25 12:18:26 +08:00
shanglei
d48c218d0d fix: close layering quality gate gaps 2026-07-24 17:49:33 +08:00
shanglei
abe0d09d4b fix(qualitygate): harden layering edge parsing and release-target checks
Layering edge parsing and graph coverage:
- Reject whitespace-padded exception fields instead of silently trimming
  them, so a padded row is a malformed row rather than a coerced identity;
  add a padded-field parse test.
- Fail loud when any release target/tag combination lists zero packages,
  which would otherwise let the layering graph silently under-cover.
- Document the build-tag scope (demo tags excluded), the SkipFrom substring
  semantics, and the toolchain-derived support set behind the drift check.

GoReleaser drift checks:
- Reject custom build commands and per-target overrides as unsupported.
- Detect --tags in addition to -tags when rejecting release build tags.
- Reject any GO* build environment variable (except CGO_ENABLED=0) through a
  single default branch instead of an explicit allowlist.
- Validate the GoReleaser global env block, and make the go-list stderr test
  table-driven across the default and authsidecar graphs.
2026-07-24 17:21:21 +08:00
shanglei
7c2ca4e465 test(qualitygate): cover default release graph 2026-07-24 16:34:22 +08:00
shanglei
cbe0fb12df test(qualitygate): reject unsupported release variants 2026-07-24 16:29:47 +08:00
shanglei
59b6393250 test(qualitygate): fail closed on release target drift 2026-07-24 16:24:30 +08:00
shanglei
f1ce88b48e test(qualitygate): pin release target coverage 2026-07-24 16:16:55 +08:00
shanglei
c09b0d5dd3 test(qualitygate): tolerate coverage helper diagnostics 2026-07-24 16:09:25 +08:00
shanglei
1772afe22d fix(qualitygate): cover release build graphs
Check all seven published GOOS and GOARCH combinations, and keep go list diagnostics separate from its JSON output for cold caches.\n\nMake the bootstrap snapshot immutable in CI, propagate shell failures explicitly, isolate sourced execution, and add deterministic regression tests for each contract.
2026-07-24 16:01:45 +08:00
shanglei
acd50f25fa fix(qualitygate): harden layering ratchet enforcement 2026-07-24 15:41:51 +08:00
shanglei
e488cf4cd3 feat(qualitygate): enforce six-layer package dependency boundaries
Add a data-driven architecture layering test that builds the full import
graph (go list -json -tags authsidecar) and evaluates six rules:

- extension must not depend on internal (transitive; keeps it extractable
  as a standalone SDK module)
- events must not depend on shortcuts (transitive)
- shortcuts must not directly import auth/keychain/credential/client/vfs
  (direct; must go through the shortcuts/common RuntimeContext gate)
- cmd subpackages must not import shortcuts (assembly point + cmd/auth only)
- errs must stay a leaf
- internal must not depend on cmd/shortcuts/events

Pre-existing violations are seeded into layering-edges.txt (37 rows). The
test rejects any unseeded violation (new debt) and any stale row (removed
debt), and CI locks the effective row count to only ever decrease. Removes
the tautological circular-dependency check from arch-audit.yml, since Go
already forbids import cycles at compile time.
2026-07-24 15:18:45 +08:00
8 changed files with 2221 additions and 13 deletions

View File

@@ -62,19 +62,6 @@ jobs:
go list -m -u all 2>/dev/null | grep '\[' >> report.md || echo "All dependencies up to date" >> report.md
echo '```' >> report.md
- name: Circular dependency check
run: |
echo "## Circular Dependencies" >> report.md
go list -f '{{.ImportPath}} {{join .Imports " "}}' ./... | \
go run golang.org/x/tools/cmd/digraph@v0.31.0 scc 2>&1 | tee cycles.txt
if [ -s cycles.txt ]; then
echo '```' >> report.md
cat cycles.txt >> report.md
echo '```' >> report.md
else
echo "No circular dependencies detected." >> report.md
fi
- name: E2E coverage gaps
run: |
echo "## E2E Coverage Gaps" >> report.md

View File

@@ -119,6 +119,8 @@ jobs:
env:
QUALITY_GATE_CHANGED_FROM: ${{ github.event.pull_request.base.sha || github.event.before || 'origin/main' }}
run: echo "QUALITY_GATE_CHANGED_FROM=$(bash scripts/resolve-changed-from.sh)" >> "$GITHUB_ENV"
- name: Enforce layering ratchet
run: bash scripts/check-layering-ratchet.sh "$QUALITY_GATE_CHANGED_FROM"
- name: Run golangci-lint
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev="$QUALITY_GATE_CHANGED_FROM"
- name: Run source-contract lint guards (lintcheck)

View File

@@ -49,6 +49,7 @@ fmt-check:
script-test:
bash scripts/resolve-changed-from.test.sh
bash scripts/check-layering-ratchet.test.sh
bash scripts/ci-workflow.test.sh
bash scripts/semantic-review-workflow.test.sh
$(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/install.test.js scripts/release-preflight.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js

View File

@@ -0,0 +1,40 @@
# from denied owner reason added_at
github.com/larksuite/cli/extension/credential/env github.com/larksuite/cli/internal/charcheck arch-migration extension pre-module-split (via core/envvars) 2026-07-24
github.com/larksuite/cli/extension/credential/env github.com/larksuite/cli/internal/core arch-migration extension pre-module-split (via core/envvars) 2026-07-24
github.com/larksuite/cli/extension/credential/env github.com/larksuite/cli/internal/envvars arch-migration extension pre-module-split (via core/envvars) 2026-07-24
github.com/larksuite/cli/extension/credential/env github.com/larksuite/cli/internal/i18n arch-migration extension pre-module-split (via core/envvars) 2026-07-24
github.com/larksuite/cli/extension/credential/env github.com/larksuite/cli/internal/keychain arch-migration extension pre-module-split (via core/envvars) 2026-07-24
github.com/larksuite/cli/extension/credential/env github.com/larksuite/cli/internal/validate arch-migration extension pre-module-split (via core/envvars) 2026-07-24
github.com/larksuite/cli/extension/credential/env github.com/larksuite/cli/internal/vfs arch-migration extension pre-module-split (via core/envvars) 2026-07-24
github.com/larksuite/cli/extension/credential/env github.com/larksuite/cli/internal/vfs/localfileio arch-migration extension pre-module-split (via core/envvars) 2026-07-24
github.com/larksuite/cli/extension/credential/sidecar github.com/larksuite/cli/internal/charcheck arch-migration extension pre-module-split (via core/envvars) 2026-07-24
github.com/larksuite/cli/extension/credential/sidecar github.com/larksuite/cli/internal/core arch-migration extension pre-module-split (via core/envvars) 2026-07-24
github.com/larksuite/cli/extension/credential/sidecar github.com/larksuite/cli/internal/envvars arch-migration extension pre-module-split (via core/envvars) 2026-07-24
github.com/larksuite/cli/extension/credential/sidecar github.com/larksuite/cli/internal/i18n arch-migration extension pre-module-split (via core/envvars) 2026-07-24
github.com/larksuite/cli/extension/credential/sidecar github.com/larksuite/cli/internal/keychain arch-migration extension pre-module-split (via core/envvars) 2026-07-24
github.com/larksuite/cli/extension/credential/sidecar github.com/larksuite/cli/internal/validate arch-migration extension pre-module-split (via core/envvars) 2026-07-24
github.com/larksuite/cli/extension/credential/sidecar github.com/larksuite/cli/internal/vfs arch-migration extension pre-module-split (via core/envvars) 2026-07-24
github.com/larksuite/cli/extension/credential/sidecar github.com/larksuite/cli/internal/vfs/localfileio arch-migration extension pre-module-split (via core/envvars) 2026-07-24
github.com/larksuite/cli/extension/transport/sidecar github.com/larksuite/cli/internal/envvars arch-migration extension pre-module-split (via core/envvars) 2026-07-24
github.com/larksuite/cli/events github.com/larksuite/cli/shortcuts/im/convert_lib arch-migration events root aggregates the existing im conversion dependency 2026-07-24
github.com/larksuite/cli/events github.com/larksuite/cli/shortcuts/common arch-migration events root aggregates the existing im conversion dependency 2026-07-24
github.com/larksuite/cli/events/im github.com/larksuite/cli/shortcuts/im/convert_lib arch-migration events->shortcuts inversion via convert_lib 2026-07-24
github.com/larksuite/cli/events/im github.com/larksuite/cli/shortcuts/common arch-migration events->shortcuts inversion via convert_lib 2026-07-24
github.com/larksuite/cli/shortcuts/im github.com/larksuite/cli/internal/auth arch-migration shortcut bypasses RuntimeContext gate 2026-07-24
github.com/larksuite/cli/shortcuts/mail github.com/larksuite/cli/internal/auth arch-migration shortcut bypasses RuntimeContext gate 2026-07-24
github.com/larksuite/cli/shortcuts/minutes github.com/larksuite/cli/internal/auth arch-migration shortcut bypasses RuntimeContext gate 2026-07-24
github.com/larksuite/cli/shortcuts/vc github.com/larksuite/cli/internal/auth arch-migration shortcut bypasses RuntimeContext gate 2026-07-24
github.com/larksuite/cli/shortcuts/apps github.com/larksuite/cli/internal/keychain arch-migration shortcut bypasses RuntimeContext gate 2026-07-24
github.com/larksuite/cli/shortcuts/drive github.com/larksuite/cli/internal/credential arch-migration shortcut bypasses RuntimeContext gate 2026-07-24
github.com/larksuite/cli/shortcuts/im github.com/larksuite/cli/internal/credential arch-migration shortcut bypasses RuntimeContext gate 2026-07-24
github.com/larksuite/cli/shortcuts/minutes github.com/larksuite/cli/internal/credential arch-migration shortcut bypasses RuntimeContext gate 2026-07-24
github.com/larksuite/cli/shortcuts/vc github.com/larksuite/cli/internal/credential arch-migration shortcut bypasses RuntimeContext gate 2026-07-24
github.com/larksuite/cli/shortcuts/apps github.com/larksuite/cli/internal/client arch-migration shortcut bypasses RuntimeContext gate 2026-07-24
github.com/larksuite/cli/shortcuts/drive github.com/larksuite/cli/internal/client arch-migration shortcut bypasses RuntimeContext gate 2026-07-24
github.com/larksuite/cli/shortcuts/im github.com/larksuite/cli/internal/client arch-migration shortcut bypasses RuntimeContext gate 2026-07-24
github.com/larksuite/cli/shortcuts/mail github.com/larksuite/cli/internal/client arch-migration shortcut bypasses RuntimeContext gate 2026-07-24
github.com/larksuite/cli/shortcuts/markdown github.com/larksuite/cli/internal/client arch-migration shortcut bypasses RuntimeContext gate 2026-07-24
github.com/larksuite/cli/shortcuts/task github.com/larksuite/cli/internal/client arch-migration shortcut bypasses RuntimeContext gate 2026-07-24
github.com/larksuite/cli/shortcuts/apps github.com/larksuite/cli/internal/vfs arch-migration shortcut bypasses RuntimeContext gate 2026-07-24
github.com/larksuite/cli/shortcuts/event github.com/larksuite/cli/internal/vfs arch-migration shortcut bypasses RuntimeContext gate 2026-07-24
github.com/larksuite/cli/shortcuts/mail github.com/larksuite/cli/internal/vfs arch-migration shortcut bypasses RuntimeContext gate 2026-07-24

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,140 @@
#!/usr/bin/env bash
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
layering_ratchet_extract_keys() {
local source_file="$1"
local output_file="$2"
awk -F '\t' '
function trim(value) {
sub(/^[[:space:]]+/, "", value)
sub(/[[:space:]]+$/, "", value)
return value
}
{
content = trim($0)
if (content == "" || substr(content, 1, 1) == "#") {
next
}
if (NF != 5) {
printf "Malformed layering ratchet row at %s:%d: expected five tab-separated fields.\n", FILENAME, FNR > "/dev/stderr"
exit 2
}
from = $1
denied = $2
owner = $3
reason = $4
added_at = $5
sub(/\r+$/, "", added_at)
if (from != trim(from) || denied != trim(denied) || owner != trim(owner) || reason != trim(reason) || added_at != trim(added_at)) {
printf "Malformed layering ratchet row at %s:%d: fields must not have surrounding whitespace.\n", FILENAME, FNR > "/dev/stderr"
exit 2
}
if (from == "" || denied == "" || owner == "" || reason == "" || added_at !~ /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/) {
printf "Malformed layering ratchet row at %s:%d: fields must be non-empty and added_at must use YYYY-MM-DD.\n", FILENAME, FNR > "/dev/stderr"
exit 2
}
print from "\t" denied
}
' "$source_file" | LC_ALL=C sort >"$output_file" || return
local duplicates
duplicates="$(uniq -d "$output_file")" || return
if [[ -n "$duplicates" ]]; then
echo "Layering ratchet contains duplicate (from, denied) keys: $source_file" >&2
printf '%s\n' "$duplicates" >&2
return 1
fi
}
layering_ratchet_hash_keys() {
local source_file="$1"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$source_file" | awk '{ print $1 }' || return
return
fi
if command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$source_file" | awk '{ print $1 }' || return
return
fi
echo "Layering ratchet requires sha256sum or shasum." >&2
return 1
}
layering_ratchet_validate_bootstrap_snapshot() {
local current_keys="$1"
local expected_count="$2"
local expected_hash="$3"
local current_count
local current_hash
current_count="$(wc -l <"$current_keys" | tr -d '[:space:]')" || return
current_hash="$(layering_ratchet_hash_keys "$current_keys")" || return
if [[ "$current_count" != "$expected_count" || "$current_hash" != "$expected_hash" ]]; then
echo "::error::Layering ratchet bootstrap differs from the approved $expected_count-edge snapshot." >&2
return 1
fi
}
layering_ratchet_main() (
set -euo pipefail
local ratchet_file="internal/qualitygate/deptest/layering-edges.txt"
if root="$(git rev-parse --show-toplevel 2>/dev/null)"; then
cd "$root" || return
else
echo "Layering ratchet must run inside a Git worktree." >&2
return 1
fi
local base_revision="${1:-${QUALITY_GATE_CHANGED_FROM:-}}"
local approved_initial_count="${2:-39}"
local approved_initial_hash="${3:-5636d50d10b9de1e08dc9f06cd66671b3f438650fa5b7f28b95aa7d5a69a1c21}"
if [[ -z "$base_revision" ]]; then
echo "Layering ratchet requires a base revision." >&2
return 1
fi
if ! git cat-file -e "$base_revision^{commit}" 2>/dev/null; then
echo "Layering ratchet base revision does not exist: $base_revision" >&2
return 1
fi
if [[ ! -f "$ratchet_file" ]]; then
echo "Layering ratchet file is missing: $ratchet_file" >&2
return 1
fi
local tmp_dir
tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/layering-ratchet.XXXXXX")" || return
local base_file="$tmp_dir/base.txt"
local base_keys="$tmp_dir/base.keys"
local current_keys="$tmp_dir/current.keys"
local additions="$tmp_dir/additions.keys"
layering_ratchet_cleanup_current_run() {
rm -f "$base_file" "$base_keys" "$current_keys" "$additions"
rmdir "$tmp_dir"
}
trap layering_ratchet_cleanup_current_run EXIT
layering_ratchet_extract_keys "$ratchet_file" "$current_keys" || return
if ! git cat-file -e "$base_revision:$ratchet_file" 2>/dev/null; then
layering_ratchet_validate_bootstrap_snapshot "$current_keys" "$approved_initial_count" "$approved_initial_hash" || return
return
fi
git show "$base_revision:$ratchet_file" >"$base_file" || return
layering_ratchet_extract_keys "$base_file" "$base_keys" || return
LC_ALL=C comm -13 "$base_keys" "$current_keys" >"$additions" || return
if [[ -s "$additions" ]]; then
echo "::error::Layering ratchet contains new (from, denied) keys. Fix the dependency instead of adding rows." >&2
while IFS=$'\t' read -r from denied; do
printf 'from=%s denied=%s\n' "$from" "$denied" >&2
done <"$additions"
return 1
fi
)
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
layering_ratchet_main "${1:-${QUALITY_GATE_CHANGED_FROM:-}}"
fi

View File

@@ -0,0 +1,278 @@
#!/usr/bin/env bash
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
script="$repo_root/scripts/check-layering-ratchet.sh"
ratchet_file="internal/qualitygate/deptest/layering-edges.txt"
tmp="$(mktemp -d "${TMPDIR:-/tmp}/check-layering-ratchet-test.XXXXXX")"
source "$script"
cleanup_tmp() {
rm -rf "$tmp"
}
trap cleanup_tmp EXIT
row() {
printf '%s\t%s\towner\treason\t2026-07-24\n' "$1" "$2"
}
git_init() {
local dir="$1"
git init -q -b main "$dir"
git -C "$dir" config user.name test
git -C "$dir" config user.email test@example.com
mkdir -p "$dir/$(dirname "$ratchet_file")"
}
write_rows() {
local dir="$1"
shift
{
printf '# from\tdenied\towner\treason\tadded_at\n'
while (( $# > 0 )); do
row "$1" "$2"
shift 2
done
} >"$dir/$ratchet_file"
}
commit_ratchet() {
local dir="$1"
git -C "$dir" add "$ratchet_file"
git -C "$dir" commit -q -m "ratchet"
}
expect_pass() {
local dir="$1"
local base="$2"
if ! (cd "$dir" && bash "$script" "$base"); then
echo "Expected layering ratchet check to pass in $dir." >&2
return 1
fi
}
expect_fail() {
local dir="$1"
local base="$2"
local expected="$3"
local output
if output="$(cd "$dir" && bash "$script" "$base" 2>&1)"; then
echo "Expected layering ratchet check to fail in $dir." >&2
return 1
fi
if ! grep -Fq "$expected" <<<"$output"; then
printf 'Layering ratchet failure did not include %q:\n%s\n' "$expected" "$output" >&2
return 1
fi
}
hash_file() {
local source_file="$1"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$source_file" | awk '{ print $1 }'
else
shasum -a 256 "$source_file" | awk '{ print $1 }'
fi
}
bootstrap_keys() {
local source_file="$1"
awk -F '\t' 'NF == 5 && $1 !~ /^[[:space:]]*#/ { print $1 "\t" $2 }' "$source_file" | LC_ALL=C sort
}
expect_bootstrap_pass() {
local dir="$1"
local base="$2"
local count="$3"
local hash="$4"
if ! (cd "$dir" && layering_ratchet_main "$base" "$count" "$hash"); then
echo "Expected layering ratchet bootstrap check to pass in $dir." >&2
return 1
fi
}
expect_bootstrap_fail() {
local dir="$1"
local base="$2"
local count="$3"
local hash="$4"
local output
if output="$(cd "$dir" && layering_ratchet_main "$base" "$count" "$hash" 2>&1)"; then
echo "Expected layering ratchet bootstrap check to fail in $dir." >&2
return 1
fi
if ! grep -Fq "bootstrap differs from the approved" <<<"$output"; then
printf 'Unexpected layering ratchet bootstrap failure:\n%s\n' "$output" >&2
return 1
fi
}
expect_sourced_main_fail() {
local dir="$1"
local base="$2"
local expected="$3"
local output
if output="$(cd "$dir" && layering_ratchet_main "$base" 2>&1)"; then
echo "Expected sourced layering ratchet main to fail in $dir." >&2
return 1
fi
if ! grep -Fq "$expected" <<<"$output"; then
printf 'Sourced layering ratchet failure did not include %q:\n%s\n' "$expected" "$output" >&2
return 1
fi
}
test_unchanged_and_metadata_changes_pass() {
local dir="$tmp/unchanged"
git_init "$dir"
write_rows "$dir" from/a denied/a from/b denied/b
commit_ratchet "$dir"
local base
base="$(git -C "$dir" rev-parse HEAD)"
expect_pass "$dir" "$base"
sed -i.bak 's/\towner\treason\t/\tnew-owner\tnew-reason\t/' "$dir/$ratchet_file"
rm -f "$dir/$ratchet_file.bak"
expect_pass "$dir" "$base"
}
test_deletion_passes() {
local dir="$tmp/deletion"
git_init "$dir"
write_rows "$dir" from/a denied/a from/b denied/b
commit_ratchet "$dir"
local base
base="$(git -C "$dir" rev-parse HEAD)"
write_rows "$dir" from/a denied/a
expect_pass "$dir" "$base"
}
test_addition_fails() {
local dir="$tmp/addition"
git_init "$dir"
write_rows "$dir" from/a denied/a
commit_ratchet "$dir"
local base
base="$(git -C "$dir" rev-parse HEAD)"
write_rows "$dir" from/a denied/a from/b denied/b
expect_fail "$dir" "$base" "from=from/b denied=denied/b"
}
test_equal_count_replacement_fails() {
local dir="$tmp/replacement"
git_init "$dir"
write_rows "$dir" from/a denied/a from/b denied/b
commit_ratchet "$dir"
local base
base="$(git -C "$dir" rev-parse HEAD)"
write_rows "$dir" from/a denied/a from/c denied/c
expect_fail "$dir" "$base" "from=from/c denied=denied/c"
}
test_malformed_and_missing_current_file_fail() {
local dir="$tmp/malformed"
git_init "$dir"
write_rows "$dir" from/a denied/a
commit_ratchet "$dir"
local base
base="$(git -C "$dir" rev-parse HEAD)"
printf 'from/a\tdenied/a\towner\treason\n' >"$dir/$ratchet_file"
expect_fail "$dir" "$base" "expected five tab-separated fields"
expect_sourced_main_fail "$dir" "$base" "expected five tab-separated fields"
rm -f "$dir/$ratchet_file"
expect_fail "$dir" "$base" "Layering ratchet file is missing"
}
test_surrounding_whitespace_fails() {
local dir="$tmp/surrounding-whitespace"
git_init "$dir"
write_rows "$dir" from/a denied/a
commit_ratchet "$dir"
local base
base="$(git -C "$dir" rev-parse HEAD)"
printf '# from\tdenied\towner\treason\tadded_at\nfrom/a\t denied/a \towner\treason\t2026-07-24\n' >"$dir/$ratchet_file"
expect_fail "$dir" "$base" "fields must not have surrounding whitespace"
expect_sourced_main_fail "$dir" "$base" "fields must not have surrounding whitespace"
}
test_crlf_rows_pass() {
local dir="$tmp/crlf"
git_init "$dir"
write_rows "$dir" from/a denied/a
commit_ratchet "$dir"
local base
base="$(git -C "$dir" rev-parse HEAD)"
printf '# from\tdenied\towner\treason\tadded_at\r\nfrom/a\tdenied/a\towner\treason\t2026-07-24\r\n' >"$dir/$ratchet_file"
expect_pass "$dir" "$base"
}
test_duplicate_key_fails() {
local dir="$tmp/duplicate"
git_init "$dir"
write_rows "$dir" from/a denied/a
commit_ratchet "$dir"
local base
base="$(git -C "$dir" rev-parse HEAD)"
write_rows "$dir" from/a denied/a from/a denied/a
expect_fail "$dir" "$base" "duplicate (from, denied) keys"
}
test_bootstrap_requires_the_approved_snapshot() {
local dir="$tmp/bootstrap"
git_init "$dir"
printf 'base\n' >"$dir/base.txt"
git -C "$dir" add base.txt
git -C "$dir" commit -q -m "base"
local base
base="$(git -C "$dir" rev-parse HEAD)"
local args=()
local index
for index in $(seq 1 39); do
args+=("from/$index" "denied/$index")
done
write_rows "$dir" "${args[@]}"
local keys_file="$dir/initial.keys"
bootstrap_keys "$dir/$ratchet_file" >"$keys_file"
local expected_hash
expected_hash="$(hash_file "$keys_file")"
expect_bootstrap_pass "$dir" "$base" 39 "$expected_hash"
args[76]="from/replacement"
write_rows "$dir" "${args[@]}"
expect_bootstrap_fail "$dir" "$base" 39 "$expected_hash"
args[76]="from/39"
args+=("from/40" "denied/40")
write_rows "$dir" "${args[@]}"
expect_bootstrap_fail "$dir" "$base" 39 "$expected_hash"
}
test_invalid_base_fails() {
local dir="$tmp/invalid-base"
git_init "$dir"
write_rows "$dir" from/a denied/a
commit_ratchet "$dir"
expect_fail "$dir" missing-revision "base revision does not exist"
}
test_unchanged_and_metadata_changes_pass
test_deletion_passes
test_addition_fails
test_equal_count_replacement_fails
test_malformed_and_missing_current_file_fail
test_surrounding_whitespace_fails
test_crlf_rows_pass
test_duplicate_key_fails
test_bootstrap_requires_the_approved_snapshot
test_invalid_base_fails

View File

@@ -170,6 +170,21 @@ if grep -Fq '${{ secrets.' <<<"$script_test_section"; then
exit 1
fi
if ! grep -Fq 'bash scripts/check-layering-ratchet.sh "$QUALITY_GATE_CHANGED_FROM"' <<<"$lint_section"; then
echo "lint should enforce the layering ratchet with the tested key-set checker"
exit 1
fi
if grep -Fq "grep -vcE" <<<"$lint_section"; then
echo "lint should not enforce the layering ratchet by row count alone"
exit 1
fi
if grep -Fq "LAYERING_RATCHET_INITIAL_" "$workflow"; then
echo "CI must use the immutable checked-in layering bootstrap snapshot"
exit 1
fi
if grep -Fq "metadata-gate:" "$workflow"; then
echo "metadata-gate should not run alongside deterministic-gate because both would upload the same facts artifact"
exit 1