yadflow 3.18.1 → 4.0.0-next.1
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 +355 -0
- package/README.md +79 -26
- package/bin/commands.mjs +41 -0
- package/bin/yad.mjs +437 -124
- package/cli/artifact-status.mjs +34 -15
- package/cli/checkpoint.mjs +69 -49
- package/cli/codeowners-command.mjs +170 -0
- package/cli/codeowners.mjs +397 -0
- package/cli/commit.mjs +13 -9
- package/cli/companion.mjs +2 -2
- package/cli/dial.mjs +183 -0
- package/cli/docs.mjs +88 -32
- package/cli/doctor.mjs +1472 -97
- package/cli/epic-state.mjs +3478 -232
- package/cli/epic.mjs +506 -0
- package/cli/errors.mjs +4 -1
- package/cli/gate.mjs +1002 -209
- package/cli/history.mjs +556 -0
- package/cli/hook.mjs +266 -55
- package/cli/hubcommit.mjs +6 -17
- package/cli/index-command.mjs +87 -0
- package/cli/ledger.mjs +57 -7
- package/cli/lib.mjs +184 -18
- package/cli/manifest.mjs +367 -56
- package/cli/migrate.mjs +726 -53
- package/cli/mode.mjs +170 -0
- package/cli/next.mjs +349 -90
- package/cli/openpr.mjs +191 -39
- package/cli/people.mjs +654 -0
- package/cli/plan.mjs +417 -132
- package/cli/platform.mjs +110 -129
- package/cli/product-index.mjs +287 -0
- package/cli/protection.mjs +706 -0
- package/cli/reconcile.mjs +38 -12
- package/cli/repo-publish.mjs +24 -26
- package/cli/repo.mjs +23 -14
- package/cli/report.mjs +21 -15
- package/cli/review.mjs +24 -27
- package/cli/riskmap-command.mjs +289 -0
- package/cli/riskmap.mjs +373 -0
- package/cli/setup.mjs +139 -287
- package/cli/ship.mjs +7 -6
- package/cli/skill.mjs +180 -0
- package/cli/skip.mjs +211 -30
- package/cli/thread.mjs +42 -17
- package/cli/tidy.mjs +20 -20
- package/cli/update-commit.mjs +22 -22
- package/cli/usage.mjs +115 -109
- package/package.json +3 -3
- package/skills/sdlc/config.yaml +166 -87
- package/skills/sdlc/module-help.csv +35 -35
- package/skills/yad-analysis/SKILL.md +125 -65
- package/skills/yad-architecture/SKILL.md +34 -23
- package/skills/yad-architecture/references/contract-format.md +10 -8
- package/skills/yad-backfill/SKILL.md +14 -8
- package/skills/yad-backfill/references/backfill.md +1 -1
- package/skills/yad-change/SKILL.md +127 -52
- package/skills/yad-change/references/triage.md +42 -28
- package/skills/yad-checks/SKILL.md +89 -45
- package/skills/yad-checks/references/check-gates.md +315 -92
- package/skills/yad-checks/templates/checks/build-test-lint.sh +25 -7
- package/skills/yad-checks/templates/checks/commit-message.sh +17 -3
- package/skills/yad-checks/templates/checks/contract-check.sh +58 -2
- package/skills/yad-checks/templates/checks/epic-open.sh +3 -3
- package/skills/yad-checks/templates/checks/install-deps.sh +46 -0
- package/skills/yad-checks/templates/checks/ledger-guard.sh +94 -18
- package/skills/yad-checks/templates/checks/lineage-check.sh +23 -9
- package/skills/yad-checks/templates/checks/package-manager.sh +140 -0
- package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +4 -4
- package/skills/yad-checks/templates/checks/risk-map-check.sh +438 -0
- package/skills/yad-checks/templates/checks/verified-commits.sh +20 -46
- package/skills/yad-checks/templates/github/yad-checks.yml +37 -5
- package/skills/yad-checks/templates/github/yad-hub-checks.yml +5 -5
- package/skills/yad-checks/templates/github/yad-update-guard.yml +3 -4
- package/skills/yad-checks/templates/github/yad-verified-commits.yml +4 -4
- package/skills/yad-checks/templates/gitlab/.gitlab-ci.yml +7 -1
- package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +22 -4
- package/skills/yad-checks/templates/gitlab/yad-hub-checks.gitlab-ci.yml +5 -5
- package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +4 -4
- package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +91 -0
- package/skills/yad-checks/templates/hooks/ledger-guard.sh +38 -7
- package/skills/yad-commit/SKILL.md +6 -6
- package/skills/yad-connect-design/SKILL.md +6 -6
- package/skills/yad-connect-design/references/design-context.md +1 -1
- package/skills/yad-connect-design/references/design-registry.md +2 -2
- package/skills/yad-connect-docs/SKILL.md +12 -12
- package/skills/yad-connect-docs/references/docs-registry.md +1 -1
- package/skills/yad-connect-learning/SKILL.md +5 -5
- package/skills/yad-connect-learning/references/learning-registry.md +2 -2
- package/skills/yad-connect-repos/SKILL.md +92 -54
- package/skills/yad-connect-repos/references/code-context.md +6 -6
- package/skills/yad-connect-repos/references/hub-config.md +68 -58
- package/skills/yad-connect-repos/references/repos-registry.md +10 -9
- package/skills/yad-connect-repos/references/risk-map.md +81 -0
- package/skills/yad-connect-testing/SKILL.md +6 -6
- package/skills/yad-connect-testing/references/testing-context.md +3 -4
- package/skills/yad-connect-testing/references/testing-registry.md +2 -2
- package/skills/yad-defects/SKILL.md +8 -8
- package/skills/yad-discovery/SKILL.md +130 -94
- package/skills/yad-discovery/references/discovery-schema.md +23 -7
- package/skills/yad-discovery/references/foundation-schema.md +374 -0
- package/skills/yad-docs/SKILL.md +16 -11
- package/skills/yad-docs/references/data-mapping.md +9 -7
- package/skills/yad-docs/templates/app/package-lock.json +3 -3
- package/skills/yad-docs-overview/SKILL.md +32 -17
- package/skills/yad-docs-overview/references/pipeline-model.md +47 -28
- package/skills/yad-docs-sync/SKILL.md +10 -5
- package/skills/yad-docs-sync/references/staleness.md +8 -7
- package/skills/yad-engineer-review/SKILL.md +88 -24
- package/skills/yad-engineer-review/references/ship-and-record.md +25 -16
- package/skills/yad-epic/SKILL.md +178 -100
- package/skills/yad-epic/references/state-schema.md +626 -117
- package/skills/yad-hub-bridge/SKILL.md +66 -48
- package/skills/yad-hub-bridge/references/bridge.md +110 -83
- package/skills/yad-hub-bridge/references/login-roster.md +163 -70
- package/skills/yad-hub-bridge/templates/checks/hub-route.sh +22 -19
- package/skills/yad-hub-bridge/templates/github/yad-gate-sync.yml +34 -14
- package/skills/yad-hub-bridge/templates/gitlab/gitlab-ci.include-root.yml +2 -2
- package/skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml +22 -12
- package/skills/yad-implement/SKILL.md +29 -15
- package/skills/yad-implement/references/implement-conventions.md +2 -2
- package/skills/yad-learn/SKILL.md +9 -9
- package/skills/yad-learn/references/learning-state.md +2 -2
- package/skills/yad-open-pr/SKILL.md +64 -29
- package/skills/yad-pair-review/SKILL.md +18 -16
- package/skills/yad-pair-review/references/session-state.md +4 -4
- package/skills/yad-pr-template/SKILL.md +48 -27
- package/skills/yad-pr-template/references/risk-routing.md +97 -24
- package/skills/yad-pr-template/templates/checks/pr-template.sh +37 -15
- package/skills/yad-pr-template/templates/checks/pr-title.sh +27 -13
- package/skills/yad-pr-template/templates/checks/risk-route.sh +107 -14
- package/skills/yad-pr-template/templates/github/pull_request_template.md +7 -5
- package/skills/yad-pr-template/templates/gitlab/merge_request_templates/Default.md +7 -5
- package/skills/yad-pr-template/templates/hub/github/pull_request_template.md +15 -14
- package/skills/yad-pr-template/templates/hub/gitlab/merge_request_templates/Default.md +15 -13
- package/skills/yad-reconcile/SKILL.md +3 -3
- package/skills/yad-report/SKILL.md +5 -5
- package/skills/yad-review-companion/SKILL.md +12 -9
- package/skills/yad-review-gate/SKILL.md +198 -79
- package/skills/yad-review-gate/references/gating.md +230 -54
- package/skills/yad-run/SKILL.md +86 -56
- package/skills/yad-run/references/run-loop.md +67 -45
- package/skills/yad-ship/SKILL.md +18 -14
- package/skills/yad-spec/SKILL.md +31 -17
- package/skills/yad-spec/references/spec-handoff.md +17 -5
- package/skills/yad-status/SKILL.md +114 -56
- package/skills/yad-stories/SKILL.md +42 -27
- package/skills/yad-stories/references/story-schema.md +10 -9
- package/skills/yad-stub/SKILL.md +59 -48
- package/skills/yad-sync-repos/SKILL.md +3 -3
- package/skills/yad-test-cases/SKILL.md +37 -30
- package/skills/yad-test-cases/references/test-cases-schema.md +8 -5
- package/skills/yad-timeline/SKILL.md +8 -7
- package/skills/yad-ui/SKILL.md +46 -25
- package/cli/roster.mjs +0 -164
- package/skills/sdlc/install.sh +0 -68
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Shared package-manager detection for the dependency installer and build/test/lint gate.
|
|
3
|
+
# The standard package.json `packageManager` field is authoritative. A pnpm lockfile is a
|
|
4
|
+
# compatibility fallback for local gate execution, but CI installation requires an exact pnpm
|
|
5
|
+
# version so a generated workflow never silently changes toolchains.
|
|
6
|
+
|
|
7
|
+
readonly YAD_PACKAGE_JSON="package.json"
|
|
8
|
+
readonly YAD_PNPM_LOCKFILE="pnpm-lock.yaml"
|
|
9
|
+
readonly YAD_NPM_LOCKFILE="package-lock.json"
|
|
10
|
+
readonly YAD_NPM_SHRINKWRAP="npm-shrinkwrap.json"
|
|
11
|
+
|
|
12
|
+
# Corepack is the Node tool that downloads and activates the declared version. It is bundled with
|
|
13
|
+
# Node 18 through 24 (Node 25+ dropped it), and a copy older than 0.31 — before Node 18.20.7 /
|
|
14
|
+
# 20.19 / 22.14 — predates the 2025 npm registry key rotation and rejects anything published since
|
|
15
|
+
# with a raw "Cannot find matching keyid". A declared packageManager without a working Corepack must
|
|
16
|
+
# say so instead of dying with "command not found" or that error in the middle of the job.
|
|
17
|
+
readonly YAD_COREPACK_LINES="set YAD_NODE_VERSION to a current 20, 22 or 24 line, or install a current Corepack first (npm install -g corepack)"
|
|
18
|
+
yad_require_corepack() {
|
|
19
|
+
if command -v corepack >/dev/null 2>&1; then return 0; fi
|
|
20
|
+
echo "FAIL [package-manager]: package.json#packageManager is set but corepack is not on PATH (Node 25+ no longer bundles it) — $YAD_COREPACK_LINES." >&2
|
|
21
|
+
return 1
|
|
22
|
+
}
|
|
23
|
+
# `corepack prepare` with the guidance attached to every way it can fail: a stale Corepack (the
|
|
24
|
+
# keyid error above), no network, or a version the registry does not have.
|
|
25
|
+
yad_corepack_prepare() {
|
|
26
|
+
local spec="$1"
|
|
27
|
+
if corepack prepare "$spec" --activate; then return 0; fi
|
|
28
|
+
echo "FAIL [package-manager]: corepack could not activate $spec. A Corepack older than 0.31 cannot verify packages signed after the 2025 npm registry key rotation — $YAD_COREPACK_LINES; otherwise check that the version exists and the registry is reachable." >&2
|
|
29
|
+
return 1
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# The command that runs the declared manager. Corepack activates a pinned npm but never shims it
|
|
33
|
+
# (`corepack enable` covers pnpm and yarn only), so a bare `npm` would be the Node image's ambient
|
|
34
|
+
# npm; a pinned npm is dispatched through Corepack instead, for `npm ci` and for the gate's `run`s.
|
|
35
|
+
# Prints the words space-separated so callers can `read -r -a` them into an array (bash 3.2 has no
|
|
36
|
+
# mapfile); the words are fixed literals, never repo input.
|
|
37
|
+
yad_package_manager_command() {
|
|
38
|
+
local spec="$1" manager="$2"
|
|
39
|
+
if [[ "$spec" == npm@* ]]; then
|
|
40
|
+
yad_require_corepack || return 1
|
|
41
|
+
printf '%s\n' "corepack npm"
|
|
42
|
+
else
|
|
43
|
+
printf '%s\n' "$manager"
|
|
44
|
+
fi
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
yad_validate_exact_package_manager_spec() {
|
|
48
|
+
local spec="$1"
|
|
49
|
+
# The JavaScript is deliberately single-quoted so Bash cannot expand its template literals.
|
|
50
|
+
# shellcheck disable=SC2016
|
|
51
|
+
YAD_PACKAGE_MANAGER_SPEC="$spec" node --input-type=module -e '
|
|
52
|
+
const spec = process.env.YAD_PACKAGE_MANAGER_SPEC;
|
|
53
|
+
const at = spec.indexOf("@");
|
|
54
|
+
const version = spec.slice(at + 1);
|
|
55
|
+
// The Corepack integrity suffix is `+<algorithm>.<hex digest>`: it hashes the download with the
|
|
56
|
+
// named algorithm and compares the LOWERCASE hex string byte for byte. Every algorithm Corepack
|
|
57
|
+
// has written or accepts is listed with its digest length; anything else is not a Corepack
|
|
58
|
+
// suffix and fails closed.
|
|
59
|
+
const COREPACK_DIGEST_HEX_LENGTH = { sha1: 40, sha224: 56, sha256: 64, sha384: 96, sha512: 128 };
|
|
60
|
+
const exactSemver = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
|
|
61
|
+
const match = exactSemver.exec(version);
|
|
62
|
+
const invalidNumericPrerelease = match?.[4]?.split(".").some(
|
|
63
|
+
identifier => /^\d+$/.test(identifier) && identifier.length > 1 && identifier.startsWith("0"),
|
|
64
|
+
);
|
|
65
|
+
const isCorepackIntegrity = (build) => {
|
|
66
|
+
const [algorithm, digest, ...rest] = build.split(".");
|
|
67
|
+
const length = COREPACK_DIGEST_HEX_LENGTH[algorithm];
|
|
68
|
+
return rest.length === 0 && length !== undefined && new RegExp(`^[0-9a-f]{${length}}$`).test(digest ?? "");
|
|
69
|
+
};
|
|
70
|
+
const invalidBuildMetadata = match?.[5] !== undefined && !isCorepackIntegrity(match[5]);
|
|
71
|
+
if (!match || match[0] !== version || invalidNumericPrerelease || invalidBuildMetadata) {
|
|
72
|
+
const algorithms = Object.keys(COREPACK_DIGEST_HEX_LENGTH).join("|");
|
|
73
|
+
process.stderr.write(`FAIL [package-manager]: packageManager ${JSON.stringify(spec)} must name an exact semantic version, optionally followed by the Corepack integrity suffix +<${algorithms}>.<lowercase hex digest>.\n`);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
'
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
yad_package_manager_spec() {
|
|
80
|
+
if [ ! -f "$YAD_PACKAGE_JSON" ]; then
|
|
81
|
+
echo "FAIL [package-manager]: package.json is missing." >&2
|
|
82
|
+
return 1
|
|
83
|
+
fi
|
|
84
|
+
# The JavaScript is deliberately single-quoted so Bash cannot expand its template literals.
|
|
85
|
+
# shellcheck disable=SC2016
|
|
86
|
+
node --input-type=module -e '
|
|
87
|
+
import fs from "node:fs";
|
|
88
|
+
let pkg;
|
|
89
|
+
// npm strips a UTF-8 byte-order mark before parsing (Windows editors often write one); do the same
|
|
90
|
+
// so a manifest npm itself installs from is not rejected here.
|
|
91
|
+
try { pkg = JSON.parse(fs.readFileSync("package.json", "utf8").replace(/^\uFEFF/, "")); }
|
|
92
|
+
catch { process.stderr.write("FAIL [package-manager]: package.json is not valid JSON.\n"); process.exit(1); }
|
|
93
|
+
if (pkg === null || typeof pkg !== "object" || Array.isArray(pkg)) {
|
|
94
|
+
process.stderr.write("FAIL [package-manager]: package.json must be a JSON object.\n"); process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
if (pkg.packageManager !== undefined && typeof pkg.packageManager !== "string") {
|
|
97
|
+
process.stderr.write("FAIL [package-manager]: packageManager must be a string.\n"); process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
if (pkg.packageManager && /[\r\n]/.test(pkg.packageManager)) {
|
|
100
|
+
process.stderr.write(`FAIL [package-manager]: packageManager ${JSON.stringify(pkg.packageManager)} must name an exact semantic version.\n`);
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
process.stdout.write(pkg.packageManager || "");
|
|
104
|
+
'
|
|
105
|
+
}
|
|
106
|
+
yad_detect_package_manager() {
|
|
107
|
+
local spec="${1-}"
|
|
108
|
+
case "$spec" in
|
|
109
|
+
"")
|
|
110
|
+
# No declaration: npm, exactly as before this field was read — UNLESS the repo carries a pnpm
|
|
111
|
+
# lockfile and no npm one. A repo with both (a stale package-lock.json after a migration is
|
|
112
|
+
# common) keeps the historical npm path rather than silently flipping toolchains.
|
|
113
|
+
if [ -f "$YAD_PNPM_LOCKFILE" ] && [ ! -f "$YAD_NPM_LOCKFILE" ] && [ ! -f "$YAD_NPM_SHRINKWRAP" ]; then
|
|
114
|
+
printf '%s\n' "pnpm"
|
|
115
|
+
else
|
|
116
|
+
printf '%s\n' "npm"
|
|
117
|
+
fi
|
|
118
|
+
;;
|
|
119
|
+
npm@*)
|
|
120
|
+
yad_validate_exact_package_manager_spec "$spec" || return 1
|
|
121
|
+
printf '%s\n' "npm"
|
|
122
|
+
;;
|
|
123
|
+
pnpm@*)
|
|
124
|
+
yad_validate_exact_package_manager_spec "$spec" || return 1
|
|
125
|
+
printf '%s\n' "pnpm"
|
|
126
|
+
;;
|
|
127
|
+
*)
|
|
128
|
+
# yarn/bun declared for local use while CI installs from an npm lockfile was green before this
|
|
129
|
+
# field was read; keep that repo on the historical npm path, with a warning, rather than
|
|
130
|
+
# failing every PR on upgrade. Without an npm lockfile there is nothing to fall back to.
|
|
131
|
+
if [ -f "$YAD_NPM_LOCKFILE" ] || [ -f "$YAD_NPM_SHRINKWRAP" ]; then
|
|
132
|
+
echo "WARN [package-manager]: packageManager '$spec' is not supported by the gate (npm, pnpm); an npm lockfile is present, so continuing on the historical npm path." >&2
|
|
133
|
+
printf '%s\n' "npm"
|
|
134
|
+
else
|
|
135
|
+
echo "FAIL [package-manager]: unsupported packageManager '$spec' (supported: npm, pnpm)." >&2
|
|
136
|
+
return 1
|
|
137
|
+
fi
|
|
138
|
+
;;
|
|
139
|
+
esac
|
|
140
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# reconcile-debt gate (Phase 6 — hotfix debt). A hotfix may ship code BEFORE its
|
|
3
|
-
# (ship-first), but it opens a reconcile-debt.json entry: the
|
|
2
|
+
# reconcile-debt gate (Phase 6 — hotfix debt). A hotfix may ship code BEFORE its Shape gates approve
|
|
3
|
+
# (ship-first), but it opens a reconcile-debt.json entry: the Shape artifacts do not yet describe what
|
|
4
4
|
# is in production. That debt must be PAID (artifacts updated + a regression test added) before the NEXT
|
|
5
5
|
# normal change on the same feature thread can ship. This gate FAILs a non-maintenance commit whose
|
|
6
6
|
# owning epic is on a thread carrying an OPEN debt that this epic does not itself own.
|
|
@@ -52,7 +52,7 @@ fm_val() { awk -v k="$1" 'NR==1 && /^---$/ {f=1; next} f && /^---$/ {exit} f &&
|
|
|
52
52
|
# Same, for a link.md field. yad-spec writes link.md WITH frontmatter, but code repos still carry
|
|
53
53
|
# pre-frontmatter ones that contract-check used to read with a whole-file scan — so fall back to that
|
|
54
54
|
# rather than silently reading an empty value and skipping the check it guards. Deliberately separate
|
|
55
|
-
# from fm_val:
|
|
55
|
+
# from fm_val: Product artifacts (epic.md, stories/*.md) stay bounded to their first block.
|
|
56
56
|
link_val() {
|
|
57
57
|
_v="$(fm_val "$1" "$2")"
|
|
58
58
|
[ -n "$_v" ] || _v="$(sed -nE "s/^$1:[[:space:]]*(.*)\$/\1/p" "$2" 2>/dev/null | head -1 | tr -d '\r' | sed -E 's/[[:space:]]+$//')"
|
|
@@ -148,7 +148,7 @@ while IFS= read -r sha; do
|
|
|
148
148
|
if [ -n "$blockers" ]; then
|
|
149
149
|
echo "FAIL [reconcile-debt]: thread ${thread} carries OPEN hotfix debt:"
|
|
150
150
|
printf '%s\n' "$blockers" | sed 's/^/ /'
|
|
151
|
-
echo " -> Pay it first: update the
|
|
151
|
+
echo " -> Pay it first: update the Shape artifacts + add the regression test, then mark the"
|
|
152
152
|
echo " reconcile-debt.json entry status: paid. The thread is frozen for new changes until then."
|
|
153
153
|
rc=1
|
|
154
154
|
continue
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# risk-map check (E65, E66, E67). Reads this repo's `.sdlc/risk-map` — one line per directory giving it a
|
|
3
|
+
# risk level (high / medium / low), no names — and WARNS when the map has gone stale for the change in
|
|
4
|
+
# front of it. It never fails the build: a warning is how a team keeps the map true. No AI and no Node:
|
|
5
|
+
# the same map and the same diff give the same output on every run.
|
|
6
|
+
#
|
|
7
|
+
# It also COUNTS (E66): a change touching a `high` directory adds the high step, +1 approver, printed as
|
|
8
|
+
# a `COUNT [risk-map]:` line. The count reads the map on the BASE branch, never this change's copy, so a
|
|
9
|
+
# change cannot lower its own count by editing the map. It is reported, never enforced: a Build merge is
|
|
10
|
+
# held by the platform's branch protection, and this script runs from the PR's own checkout, so a PR could
|
|
11
|
+
# edit it — harmless only because nothing blocks on it (E72). It cannot apply the capacity cap either: the
|
|
12
|
+
# cap needs the Product's count of active people, which a code repo's CI does not have; `yad open-pr`
|
|
13
|
+
# shows the capped count. A `guessed` level counts as a `confirmed` one;
|
|
14
|
+
# an `unset` line and an uncovered directory add nothing. The change's files for the count include
|
|
15
|
+
# deleted and moved-away files — deleting code in a `high` directory is a `high` change.
|
|
16
|
+
#
|
|
17
|
+
# It also names WHO can meet that ask (E67): everyone who committed in a `high` directory the change
|
|
18
|
+
# touches in the last 30 days, from the BASE branch's history, this change's own authors left out. With
|
|
19
|
+
# two `high` directories it is one list — someone who worked in any of them meets the ask. Git is asked
|
|
20
|
+
# one directory at a time, through pathspecs that carry the map's cover rule, and answers with author
|
|
21
|
+
# records only: no file name is ever read back out of git.
|
|
22
|
+
#
|
|
23
|
+
# risk-map-check.sh [<base>] the warnings, then the count (CI runs this on every PR)
|
|
24
|
+
# risk-map-check.sh --level [<base>] the count only, as machine lines, for checks/risk-route.sh:
|
|
25
|
+
# BASE <ref>
|
|
26
|
+
# UNKNOWN <why> the level could not be read — not zero
|
|
27
|
+
# NOMAP <why> the base has no map: nothing adds a step
|
|
28
|
+
# FILES <n> how many files the change touches
|
|
29
|
+
# LEVEL <high|medium|low|none>
|
|
30
|
+
# DIR <dir> <level> <guessed|confirmed> one per touched line
|
|
31
|
+
# WHO <login|-> <name> who committed lately in a high one
|
|
32
|
+
# HISTNONE that history was read, and held nobody
|
|
33
|
+
# HISTUNKNOWN <why> that history could not be read
|
|
34
|
+
#
|
|
35
|
+
# It warns about:
|
|
36
|
+
# uncovered a file this change adds or edits that no line covers — names the directory to add
|
|
37
|
+
# dead a line whose directory holds no file any more
|
|
38
|
+
# unset a line this change touches that has no level yet
|
|
39
|
+
# guessed a line this change touches whose level is still a guess (a person confirms it)
|
|
40
|
+
# unreadable a line that is not `<dir>/ <level> <guessed|confirmed>`; `names` for an `@login`;
|
|
41
|
+
# `duplicate` for a directory listed twice; `header` / `version` for the first line
|
|
42
|
+
# map-edited this change edits the map itself, which decides how much review later changes need
|
|
43
|
+
#
|
|
44
|
+
# The rules have a twin in cli/riskmap.mjs (`yad risk-map check`, `yad doctor`). Change one, change the
|
|
45
|
+
# other: a test runs both over the same repos and compares what they report.
|
|
46
|
+
set -euo pipefail
|
|
47
|
+
# Bytes, not characters, for every tool below: a Mac `tr` in a UTF-8 locale dies on a path that is not
|
|
48
|
+
# valid UTF-8 ("Illegal byte sequence"), which pipefail would turn into a failing exit, and a UTF-8-aware
|
|
49
|
+
# awk (macOS 26's) splits some non-ASCII whitespace differently from mawk, older awks and cli/riskmap.mjs.
|
|
50
|
+
export LC_ALL=C
|
|
51
|
+
# Four variables change how git reads EVERY pathspec: GIT_LITERAL_PATHSPECS would make `:(glob)*` match
|
|
52
|
+
# nothing (git still exits 0, so the history would read as "nobody"), GIT_ICASE_PATHSPECS would match
|
|
53
|
+
# `SRC/` for `src/`, and the glob pair changes what `*` means. The pathspecs here say what they mean.
|
|
54
|
+
unset GIT_LITERAL_PATHSPECS GIT_GLOB_PATHSPECS GIT_NOGLOB_PATHSPECS GIT_ICASE_PATHSPECS
|
|
55
|
+
|
|
56
|
+
# --- shared base resolution (byte-identical across the gates; they are standalone by design, so it
|
|
57
|
+
# --- is duplicated, not sourced) ---
|
|
58
|
+
# With no explicit base, RESOLVE the trunk instead of assuming a hardcoded `origin/main` — on a repo
|
|
59
|
+
# whose trunk is `develop`/`master` that guess either fails closed or, where a stale `main` still
|
|
60
|
+
# exists, silently diffs the WRONG range (issue #161). Mirrors the CLI's own order (cli/hubcommit.mjs,
|
|
61
|
+
# cli/repo.mjs): the CONFIGURED default_branch first, then the remote's published default
|
|
62
|
+
# (origin/HEAD), then origin/main. Each candidate must actually resolve before it is used, so a
|
|
63
|
+
# DANGLING origin/HEAD (trunk renamed, the old remote-tracking ref pruned) falls through to the next
|
|
64
|
+
# candidate instead of failing the gate on a fully-fetched repo. CI always passes the base explicitly,
|
|
65
|
+
# so this governs local runs only. The `|| _x=""` guards are load-bearing: under `set -e` a failing
|
|
66
|
+
# command substitution in an assignment aborts the script.
|
|
67
|
+
resolve_base() {
|
|
68
|
+
# tr first: a key and its value may legally sit on separate lines, which a per-line match misses.
|
|
69
|
+
_cfg="$(tr -d '\n' < "${SDLC_HUB_CONFIG:-.sdlc/hub.json}" 2>/dev/null | sed -nE 's/.*"default_branch"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p')" || _cfg=""
|
|
70
|
+
_head="$(git symbolic-ref --short --quiet refs/remotes/origin/HEAD 2>/dev/null)" || _head=""
|
|
71
|
+
for _c in "origin/${_cfg}" "${_head}" origin/main; do
|
|
72
|
+
case "$_c" in ''|origin/) continue ;; esac
|
|
73
|
+
if git rev-parse --verify --quiet "${_c}^{commit}" >/dev/null 2>&1; then printf '%s' "$_c"; return; fi
|
|
74
|
+
done
|
|
75
|
+
printf '%s' origin/main
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
LEVEL_ONLY=0
|
|
79
|
+
if [ "${1:-}" = "--level" ]; then LEVEL_ONLY=1; shift; fi
|
|
80
|
+
|
|
81
|
+
MAP=".sdlc/risk-map"
|
|
82
|
+
# E67's window (Part 3: expertise is a fixed, tight 30 days), in git's own words. Git filters on the
|
|
83
|
+
# COMMITTER date, so a rebased or squashed commit counts from when it landed.
|
|
84
|
+
HISTORY_WINDOW="30 days ago"
|
|
85
|
+
# Advisory means exit 0 on every input — outside a git repo too, where `set -e` would stop at git.
|
|
86
|
+
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
87
|
+
if [ "$LEVEL_ONLY" = 1 ]; then echo "UNKNOWN not inside a git repo"; exit 0; fi
|
|
88
|
+
echo "note [risk-map]: not inside a git repo — nothing to check."
|
|
89
|
+
echo "PASS [risk-map]: advisory — nothing to check."
|
|
90
|
+
exit 0
|
|
91
|
+
fi
|
|
92
|
+
|
|
93
|
+
# Every path below is from the repo root — the map, the file list, `.sdlc/hub.json` — so a run from a
|
|
94
|
+
# subfolder reads the same repo as CI does, which always runs at the root.
|
|
95
|
+
cd "$(git rev-parse --show-toplevel)"
|
|
96
|
+
|
|
97
|
+
BASE="${1:-${SDLC_BASE:-$(resolve_base)}}"
|
|
98
|
+
[ "$LEVEL_ONLY" = 1 ] || [ -n "${1:-}" ] || [ -n "${SDLC_BASE:-}" ] || echo "note [risk-map]: no base given — diffing against '${BASE}'."
|
|
99
|
+
base_ok=0
|
|
100
|
+
# A usable base resolves AND shares history with HEAD: the three-dot range below needs a merge base,
|
|
101
|
+
# which a shallow clone (a host GIT_DEPTH) or unrelated history does not have — git would exit 128.
|
|
102
|
+
if git rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null && git merge-base "$BASE" HEAD >/dev/null 2>&1; then
|
|
103
|
+
base_ok=1
|
|
104
|
+
fi
|
|
105
|
+
# The change is measured from where this branch left the base (three dots), not against the base's
|
|
106
|
+
# tip: when the base has moved on, a two-dot range would blame this change for the base's own commits
|
|
107
|
+
# (the risk map's warnings are about what THIS change touches).
|
|
108
|
+
RANGE="${BASE}...HEAD"
|
|
109
|
+
|
|
110
|
+
tmp="$(mktemp -d)"
|
|
111
|
+
trap 'rm -rf "$tmp"' EXIT
|
|
112
|
+
|
|
113
|
+
# The map's rules, in awk: read by the warnings below over this change's copy of the map, and by the
|
|
114
|
+
# count over the BASE branch's copy (`mode=level`). One program, so the two can never read a line
|
|
115
|
+
# differently. Its twin is cli/riskmap.mjs.
|
|
116
|
+
RISK_MAP_AWK='
|
|
117
|
+
function problem(code, target, msg) { np++; pc[np] = code; pt[np] = target; pm[np] = msg }
|
|
118
|
+
function warn(code, target, msg) {
|
|
119
|
+
nw++
|
|
120
|
+
if (target == "") printf "WARN [risk-map] %s: %s\n", code, msg
|
|
121
|
+
else printf "WARN [risk-map] %s %s: %s\n", code, target, msg
|
|
122
|
+
}
|
|
123
|
+
function trim(s) { sub(/^[ \t]+/, "", s); sub(/[ \t]+$/, "", s); return s }
|
|
124
|
+
function validdir(d, body, n, segs, k) {
|
|
125
|
+
if (d == "./") return 1
|
|
126
|
+
if (d !~ /\/$/ || substr(d, 1, 1) == "/" || substr(d, 1, 1) == "#" || index(d, "//")) return 0
|
|
127
|
+
if (index(d, "*") || index(d, "?") || index(d, "[") || index(d, "\\") || index(d, " ") || index(d, "\t") || index(d, "\r")) return 0
|
|
128
|
+
body = substr(d, 1, length(d) - 1)
|
|
129
|
+
n = split(body, segs, "/")
|
|
130
|
+
for (k = 1; k <= n; k++) if (segs[k] == "." || segs[k] == "..") return 0
|
|
131
|
+
return 1
|
|
132
|
+
}
|
|
133
|
+
function cover(f, i, best, bl) {
|
|
134
|
+
if (index(f, "/") == 0) { for (i = 1; i <= ne; i++) if (ed[i] == "./") return i; return 0 }
|
|
135
|
+
best = 0; bl = 0
|
|
136
|
+
for (i = 1; i <= ne; i++)
|
|
137
|
+
if (ed[i] != "./" && substr(f, 1, length(ed[i])) == ed[i] && length(ed[i]) > bl) { best = i; bl = length(ed[i]) }
|
|
138
|
+
return best
|
|
139
|
+
}
|
|
140
|
+
function toadd(f, n, segs, prefix, k, cand, i, deeper) {
|
|
141
|
+
n = split(f, segs, "/")
|
|
142
|
+
if (n == 1) return "./"
|
|
143
|
+
prefix = ""
|
|
144
|
+
for (k = 1; k < n; k++) {
|
|
145
|
+
cand = prefix segs[k] "/"
|
|
146
|
+
if (!validdir(cand)) return (prefix != "") ? prefix : cand
|
|
147
|
+
deeper = 0
|
|
148
|
+
for (i = 1; i <= ne; i++) if (ed[i] != cand && substr(ed[i], 1, length(cand)) == cand) { deeper = 1; break }
|
|
149
|
+
if (!deeper) return cand
|
|
150
|
+
prefix = cand
|
|
151
|
+
}
|
|
152
|
+
return prefix
|
|
153
|
+
}
|
|
154
|
+
FILENAME == mapf {
|
|
155
|
+
line = $0; sub(/\r$/, "", line)
|
|
156
|
+
if (line ~ /^[ \t]*$/) next
|
|
157
|
+
if (!seenfirst) {
|
|
158
|
+
seenfirst = 1
|
|
159
|
+
if (line ~ /^#[ \t]*yad-risk-map[ \t]+v[0-9]+[ \t]*$/) {
|
|
160
|
+
header = 1
|
|
161
|
+
# Compared as TEXT, leading zeros dropped: `v0` is not 1, and a huge number prints as written.
|
|
162
|
+
v = line; sub(/^.*v/, "", v); v = trim(v); sub(/^0+/, "", v); if (v == "") v = "0"
|
|
163
|
+
if (v != "1") { unsupported = 1; badv = v }
|
|
164
|
+
next
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (unsupported) next
|
|
168
|
+
# A word starting `@x` names a person — `@org/team` too — unless it ends in `/` (a directory: `@types/`).
|
|
169
|
+
nw2 = split(line, words, /[ \t]+/); named = 0
|
|
170
|
+
for (k = 1; k <= nw2; k++) if (words[k] ~ /^@[A-Za-z0-9_-]/ && words[k] !~ /\/$/) named = 1
|
|
171
|
+
if (named) problem("names", "line " FNR, "names a person (`@…`) — the map holds directories and levels only")
|
|
172
|
+
if (line ~ /^[ \t]*#/) next
|
|
173
|
+
body = line
|
|
174
|
+
if (match(line, /[ \t]#/)) body = substr(line, 1, RSTART - 1)
|
|
175
|
+
body = trim(body)
|
|
176
|
+
nf = split(body, f, /[ \t]+/)
|
|
177
|
+
if (nf < 2 || nf > 3) { problem("unreadable", "line " FNR, "expected `<dir>/ <level> <guessed|confirmed>`"); next }
|
|
178
|
+
dir = f[1]; level = f[2]; state = (nf == 3) ? f[3] : ""
|
|
179
|
+
if (!validdir(dir)) { problem("unreadable", "line " FNR, "`" dir "` is not a directory from the repo root ending in `/`"); next }
|
|
180
|
+
if (level != "high" && level != "medium" && level != "low" && level != "unset") { problem("unreadable", "line " FNR, "`" level "` is not a level (high, medium, low or unset)"); next }
|
|
181
|
+
if (level == "unset" && state == "confirmed") { problem("unreadable", "line " FNR, "an `unset` line cannot be `confirmed`"); next }
|
|
182
|
+
if ((level == "unset" && state != "" && state != "guessed") || (level != "unset" && state != "guessed" && state != "confirmed")) {
|
|
183
|
+
problem("unreadable", "line " FNR, "the third field must be `guessed` or `confirmed`"); next
|
|
184
|
+
}
|
|
185
|
+
if (dir in listed) { problem("duplicate", "line " FNR, "`" dir "` is listed again — the first line wins"); next }
|
|
186
|
+
listed[dir] = 1
|
|
187
|
+
ne++; ed[ne] = dir; el[ne] = level; es[ne] = (level == "unset") ? "" : state
|
|
188
|
+
next
|
|
189
|
+
}
|
|
190
|
+
FILENAME == logf {
|
|
191
|
+
# Author records only: `<name>\037<address>`, newest first, one run per `high` directory in map order.
|
|
192
|
+
# No file name is ever read back from git: the pathspecs already applied the cover rule of the map.
|
|
193
|
+
if ($0 == "") next
|
|
194
|
+
nl++; split($0, lf, "\037"); lan[nl] = lf[1]; lae[nl] = lf[2]
|
|
195
|
+
next
|
|
196
|
+
}
|
|
197
|
+
FILENAME == exclf { if ($0 != "") excl[tolower($0)] = 1; next }
|
|
198
|
+
FILENAME == filesf { na++; fa[na] = $0; next }
|
|
199
|
+
FILENAME == changedf { nc++; fc[nc] = $0; next }
|
|
200
|
+
function rank(l) { return (l == "high") ? 3 : (l == "medium") ? 2 : (l == "low") ? 1 : 0 }
|
|
201
|
+
# E67. The login a noreply address carries, else "" — the twin of `loginFromEmail` in cli/riskmap.mjs.
|
|
202
|
+
# The address is never printed. Matched on the lower-cased domain (a login is case-insensitive) and
|
|
203
|
+
# returned as written, because mawk has no case-insensitive flag.
|
|
204
|
+
function loginof(e, at, local, domain) {
|
|
205
|
+
at = index(e, "@")
|
|
206
|
+
if (at == 0) return ""
|
|
207
|
+
local = substr(e, 1, at - 1); domain = tolower(substr(e, at + 1))
|
|
208
|
+
if (domain == "users.noreply.github.com") {
|
|
209
|
+
sub(/^[0-9]+\+/, "", local)
|
|
210
|
+
return (local ~ /^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?$/) ? local : ""
|
|
211
|
+
}
|
|
212
|
+
if (domain == "users.noreply.gitlab.com") {
|
|
213
|
+
if (local !~ /^[0-9]+-/) return ""
|
|
214
|
+
sub(/^[0-9]+-/, "", local)
|
|
215
|
+
return (local ~ /^[A-Za-z0-9._-]+$/) ? local : ""
|
|
216
|
+
}
|
|
217
|
+
return ""
|
|
218
|
+
}
|
|
219
|
+
# A git NAME holding an `@` is never printed (the twin of `shownName` in cli/riskmap.mjs, same character
|
|
220
|
+
# classes): an address must not leak, and a printed `@word` must only ever be a real login. The login
|
|
221
|
+
# stands in, else plain words.
|
|
222
|
+
function shownname(n, l) {
|
|
223
|
+
if (index(n, "@") == 0) return n
|
|
224
|
+
if (l != "") return "@" l
|
|
225
|
+
return (n ~ /[^ \t@]@[^ \t@]/) ? "a name that is an e-mail address" : "a name written like a login"
|
|
226
|
+
}
|
|
227
|
+
# A robot, not a person: an approval can never come from one, and `dependabot[bot]` has no login.
|
|
228
|
+
function isbot(n, e) { return (tolower(trim(n)) ~ /\[bot\]$/ || tolower(e) ~ /\[bot\]@/) }
|
|
229
|
+
# E67, the --level mode`s second pass: the people to name, from the author records the per-directory
|
|
230
|
+
# queries returned. One row per person, in the order git printed them, deduped by address, the change`s
|
|
231
|
+
# own authors and every robot left out.
|
|
232
|
+
function who_out( j, key) {
|
|
233
|
+
for (j = 1; j <= nl; j++) {
|
|
234
|
+
key = tolower(lae[j])
|
|
235
|
+
if ((key in seenau) || (key in excl) || isbot(lan[j], lae[j])) continue
|
|
236
|
+
seenau[key] = 1
|
|
237
|
+
printf "WHO %s %s\n", (loginof(lae[j]) == "") ? "-" : loginof(lae[j]), shownname(lan[j], loginof(lae[j]))
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
# Every directory the base`s map lists, in map order — the shell turns them into the include/exclude
|
|
241
|
+
# pathspecs that make git apply the cover rule.
|
|
242
|
+
function dirs_out( i) { for (i = 1; i <= ne; i++) print "ENTRY " ed[i] }
|
|
243
|
+
# E66, the --level mode: the level this change takes from the map (the twin of `changeLevel` in
|
|
244
|
+
# cli/riskmap.mjs). The highest level among the lines that decide the changed files; `unset` and an
|
|
245
|
+
# uncovered file add nothing; `guessed` counts as `confirmed`. Parse problems add nothing either.
|
|
246
|
+
function level_out( j, k, i, best) {
|
|
247
|
+
if (unsupported) { print "UNKNOWN the map on the base is written for risk-map v" badv " and this release reads v1"; return }
|
|
248
|
+
for (j = 1; j <= nc; j++) { k = cover(fc[j]); if (k && el[k] != "unset") lt[k] = 1 }
|
|
249
|
+
best = ""
|
|
250
|
+
for (i = 1; i <= ne; i++) if ((i in lt) && rank(el[i]) > rank(best)) best = el[i]
|
|
251
|
+
print "FILES " (nc + 0)
|
|
252
|
+
print "LEVEL " ((best == "") ? "none" : best)
|
|
253
|
+
for (i = 1; i <= ne; i++) if (i in lt) print "DIR " ed[i] " " el[i] " " es[i]
|
|
254
|
+
}
|
|
255
|
+
END {
|
|
256
|
+
if (mode == "level") { level_out(); exit }
|
|
257
|
+
if (mode == "who") { who_out(); exit }
|
|
258
|
+
if (mode == "dirs") { dirs_out(); exit }
|
|
259
|
+
if (unsupported) {
|
|
260
|
+
warn("version", "v" badv, "written for risk-map v" badv "; this release reads v1 — nothing in it was read")
|
|
261
|
+
} else {
|
|
262
|
+
if (seenfirst && !header) warn("header", "", "no `# yad-risk-map v1` line at the top — read as version 1")
|
|
263
|
+
for (i = 1; i <= np; i++) warn(pc[i], pt[i], pm[i])
|
|
264
|
+
for (i = 1; i <= ne; i++) {
|
|
265
|
+
holds = 0
|
|
266
|
+
for (j = 1; j <= na; j++) {
|
|
267
|
+
if (ed[i] == "./") { if (index(fa[j], "/") == 0) { holds = 1; break } }
|
|
268
|
+
else if (substr(fa[j], 1, length(ed[i])) == ed[i]) { holds = 1; break }
|
|
269
|
+
}
|
|
270
|
+
if (!holds) warn("dead", ed[i], "no file is in this directory any more — remove the line or fix the path")
|
|
271
|
+
}
|
|
272
|
+
for (j = 1; j <= nc; j++) { k = cover(fc[j]); if (k) touched[k] = 1 }
|
|
273
|
+
for (i = 1; i <= ne; i++) if ((i in touched) && el[i] == "unset") warn("unset", ed[i], "has no level yet")
|
|
274
|
+
for (i = 1; i <= ne; i++) if ((i in touched) && es[i] == "guessed") warn("guessed", ed[i], "`" el[i] "` is still a guess — a person confirms it")
|
|
275
|
+
na2 = 0
|
|
276
|
+
for (j = 1; j <= nc; j++) {
|
|
277
|
+
if (cover(fc[j])) continue
|
|
278
|
+
d = toadd(fc[j])
|
|
279
|
+
if (!(d in asked)) { asked[d] = 1; na2++; ask[na2] = d }
|
|
280
|
+
}
|
|
281
|
+
for (i = 1; i <= na2; i++) {
|
|
282
|
+
if (validdir(ask[i])) warn("uncovered", ask[i], "no line gives this directory a level")
|
|
283
|
+
else warn("uncovered", ask[i], "no line gives this directory a level, and its name cannot be written in the map (a space, `#`, `*`, `?`, `[` or `\\`) — rename it")
|
|
284
|
+
}
|
|
285
|
+
for (j = 1; j <= nc; j++) if (fc[j] == ".sdlc/risk-map") { warn("map-edited", ".sdlc/risk-map", "this change edits the risk map, which decides how much review later changes need"); break }
|
|
286
|
+
}
|
|
287
|
+
if (nw) printf "PASS [risk-map]: %d warning(s) — advisory, never blocks a merge. Fix the map in this change.\n", nw
|
|
288
|
+
else print "PASS [risk-map]: every file this change touches has a confirmed level."
|
|
289
|
+
}
|
|
290
|
+
'
|
|
291
|
+
|
|
292
|
+
# The count's machine lines (see the top of this file). The map comes from the base TIP — the branch
|
|
293
|
+
# this change merges into, which the change cannot edit. The files come from the same three-dot range
|
|
294
|
+
# as the warnings, with every kind of change kept: a deleted or moved-away file counts where it was.
|
|
295
|
+
base_level() {
|
|
296
|
+
echo "BASE ${BASE}"
|
|
297
|
+
if [ "$base_ok" != 1 ]; then echo "UNKNOWN base ref '${BASE}' not found, or it shares no history with HEAD (a shallow clone?)"; return; fi
|
|
298
|
+
# Only a real file on the base is a map: a symlink or a folder of that name is not read (git would
|
|
299
|
+
# print a symlink's target as if it were the map's text). --full-tree: ls-tree reads a path from the
|
|
300
|
+
# current folder, and run from a subfolder it would find no map and quietly count zero.
|
|
301
|
+
_entry="$(git ls-tree --full-tree "$BASE" -- "$MAP")" || { echo "UNKNOWN git could not read the tree of '${BASE}'"; return; }
|
|
302
|
+
case "$_entry" in
|
|
303
|
+
"100644 blob "*|"100755 blob "*) ;;
|
|
304
|
+
"") echo "NOMAP '${BASE}' has no ${MAP}"; return ;;
|
|
305
|
+
*) echo "NOMAP '${BASE}' holds ${MAP}, but not as a file"; return ;;
|
|
306
|
+
esac
|
|
307
|
+
# Explicit guards, not `set -e`: the CI count runs this inside `$(...)`, where bash does not stop on a
|
|
308
|
+
# failing command, and an unread map or a cut-short file list would print a false zero.
|
|
309
|
+
git show "${BASE}:${MAP}" > "$tmp/basemap" || { echo "UNKNOWN git could not read ${MAP} on '${BASE}'"; return; }
|
|
310
|
+
if ! git diff --name-only -z --no-renames "$RANGE" | tr '\0' '\n' > "$tmp/all-changed"; then
|
|
311
|
+
echo "UNKNOWN git could not list the files this change touches"; return
|
|
312
|
+
fi
|
|
313
|
+
_lv="$(awk -v mode=level -v mapf="$tmp/basemap" -v filesf=/dev/null -v changedf="$tmp/all-changed" "$RISK_MAP_AWK" "$tmp/basemap" /dev/null "$tmp/all-changed")"
|
|
314
|
+
printf '%s\n' "$_lv"
|
|
315
|
+
base_history "$_lv"
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
# E67 — who has committed lately in the `high` directories this change touches, from the BASE branch's
|
|
319
|
+
# history (a change cannot add its own). Prints `WHO <login|-> <name>` per person, newest first, or
|
|
320
|
+
# `HISTUNKNOWN <why>` — never silence for a history it could not read: a shallow clone holds only the
|
|
321
|
+
# newest commits, and reading that as "nobody has worked here" would drop the ask instead of raising it.
|
|
322
|
+
base_history() {
|
|
323
|
+
_lvl="$1"
|
|
324
|
+
_high="$(printf '%s\n' "$_lvl" | awk '$1 == "DIR" && $3 == "high" { print $2 }')"
|
|
325
|
+
[ -n "$_high" ] || return 0
|
|
326
|
+
_shallow="$(git rev-parse --is-shallow-repository 2>/dev/null)" || _shallow=""
|
|
327
|
+
if [ "$_shallow" = true ]; then
|
|
328
|
+
echo "HISTUNKNOWN this is a shallow clone — it does not hold the history of those directories"; return
|
|
329
|
+
fi
|
|
330
|
+
# Every directory the base's map lists, so each query can exclude the ones below the one it asks about.
|
|
331
|
+
_all="$(awk -v mode=dirs -v mapf="$tmp/basemap" "$RISK_MAP_AWK" "$tmp/basemap" | sed -n 's/^ENTRY //p')"
|
|
332
|
+
: > "$tmp/log"
|
|
333
|
+
# ONE QUERY PER `high` DIRECTORY, in map order, and git is asked for author records only — no file
|
|
334
|
+
# name is ever read back. The pathspecs say what the MAP means by that directory: itself, minus every
|
|
335
|
+
# listed directory below it, because the deepest listed line decides and each of those answers for
|
|
336
|
+
# itself in its own query (an exclude beats a later include, so they cannot share one query). `./` is
|
|
337
|
+
# the files AT the root: `:(glob)*` matches a top-level entry only, because `*` never spans `/`.
|
|
338
|
+
# --no-merges: a merge commit is nobody's work here. --no-renames: a file moved OUT of the directory is
|
|
339
|
+
# work in it, counted where it was (E66). --full-history: without it git simplifies a path-filtered log
|
|
340
|
+
# and hides a side branch whose merge kept the other side.
|
|
341
|
+
# shellcheck disable=SC2086 # a map directory holds no space or tab (`validdir`), so the split is safe
|
|
342
|
+
for _d in $_high; do
|
|
343
|
+
if [ "$_d" = "./" ]; then
|
|
344
|
+
set -- ':(glob)*'
|
|
345
|
+
else
|
|
346
|
+
# Every path built from a MAP NAME is `:(literal)` (`./`, just above, names no directory and is
|
|
347
|
+
# `:(glob)*`): a name may legally start with `:`, and git reads that as pathspec MAGIC —
|
|
348
|
+
# `:weird/` would answer about `weird/`, and `:/` about the whole repo.
|
|
349
|
+
set -- ":(literal)${_d}"
|
|
350
|
+
for _e in $_all; do
|
|
351
|
+
case "$_e" in "$_d"?*) set -- "$@" ":(exclude,literal)${_e}" ;; esac
|
|
352
|
+
done
|
|
353
|
+
fi
|
|
354
|
+
# --no-show-signature: log.showSignature=true would print a signature check into this output, and each
|
|
355
|
+
# such line would read as a person.
|
|
356
|
+
git log "$BASE" --no-merges --no-renames --full-history --no-show-signature --since="$HISTORY_WINDOW" --format='%an%x1f%ae' -- "$@" >> "$tmp/log" \
|
|
357
|
+
|| { echo "HISTUNKNOWN git could not read the history of '${_d}' on '${BASE}'"; return; }
|
|
358
|
+
done
|
|
359
|
+
git log "${BASE}..HEAD" --no-merges --format='%ae' > "$tmp/own" \
|
|
360
|
+
|| { echo "HISTUNKNOWN git could not read this change's own authors"; return; }
|
|
361
|
+
# HISTNONE is printed when the history WAS read and held nobody. Without that positive marker a
|
|
362
|
+
# reader could not tell it from a check too old to answer at all, and would call that "nobody".
|
|
363
|
+
_who="$(awk -v mode=who -v mapf="$tmp/basemap" -v logf="$tmp/log" -v exclf="$tmp/own" \
|
|
364
|
+
"$RISK_MAP_AWK" "$tmp/basemap" "$tmp/log" "$tmp/own")"
|
|
365
|
+
if [ -n "$_who" ]; then printf '%s\n' "$_who"; else echo "HISTNONE"; fi
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if [ "$LEVEL_ONLY" = 1 ]; then base_level; exit 0; fi
|
|
369
|
+
|
|
370
|
+
# The count, said on every PR. Reported, never enforced: it is how many approvers the change ASKS for.
|
|
371
|
+
lv="$(base_level)"
|
|
372
|
+
dirs_of() { printf '%s\n' "$lv" | awk -v want="$1" '$1 == "DIR" && $3 == want { printf "%s%s%s", sep, $2, ($4 == "guessed") ? " (guessed)" : ""; sep = ", " }'; }
|
|
373
|
+
case "$lv" in
|
|
374
|
+
*"
|
|
375
|
+
UNKNOWN "*)
|
|
376
|
+
echo "note [risk-map]: not counted — $(printf '%s\n' "$lv" | sed -n 's/^UNKNOWN //p'). The count from the map is unknown, not zero." ;;
|
|
377
|
+
*"
|
|
378
|
+
NOMAP "*)
|
|
379
|
+
echo "COUNT [risk-map]: 1 approver = base 1 — $(printf '%s\n' "$lv" | sed -n 's/^NOMAP //p'), so no directory adds a step." ;;
|
|
380
|
+
*)
|
|
381
|
+
high="$(dirs_of high)"; medium="$(dirs_of medium)"
|
|
382
|
+
if [ -n "$high" ]; then
|
|
383
|
+
echo "COUNT [risk-map]: 2 approvers = base 1 + high risk 1 (high on ${BASE}: ${high}) — reported, not enforced: branch protection holds the merge."
|
|
384
|
+
else
|
|
385
|
+
echo "COUNT [risk-map]: 1 approver = base 1 — nothing this change touches is high on ${BASE}."
|
|
386
|
+
fi
|
|
387
|
+
[ -z "$medium" ] || echo " medium on ${BASE} (reported only, adds nothing): ${medium}"
|
|
388
|
+
# E67 — and who can meet the ask: an approval from someone who has worked there lately.
|
|
389
|
+
if [ -n "$high" ]; then
|
|
390
|
+
# `$1 = ""` would rebuild the line with one space between fields and squash a name's own spacing.
|
|
391
|
+
who="$(printf '%s\n' "$lv" | sed -n 's/^WHO //p' | awk '{ login = $1; name = $0; sub(/^[^ ]* /, "", name); printf "%s%s%s", sep, name, (login == "-" || name == "@" login) ? "" : " (@" login ")"; sep = ", " }')"
|
|
392
|
+
unknown_hist="$(printf '%s\n' "$lv" | sed -n 's/^HISTUNKNOWN //p')"
|
|
393
|
+
if [ -n "$unknown_hist" ]; then
|
|
394
|
+
echo " who has worked there lately: not read — ${unknown_hist}."
|
|
395
|
+
elif [ -n "$who" ]; then
|
|
396
|
+
echo " ask one of these (committed there in the last 30 days, this change's own authors left out): ${who}"
|
|
397
|
+
else
|
|
398
|
+
echo " nobody else has committed there in the last 30 days — the count above still stands."
|
|
399
|
+
fi
|
|
400
|
+
fi
|
|
401
|
+
;;
|
|
402
|
+
esac
|
|
403
|
+
|
|
404
|
+
if [ ! -f "$MAP" ]; then
|
|
405
|
+
# Deleting the map is the largest edit to it there is, so it is said, not passed over as "no map".
|
|
406
|
+
# Asked of git by path, with no pipe to cut short. --no-renames: a move away (even into a folder the
|
|
407
|
+
# pathspec also matches, `.sdlc/risk-map/x`) is a delete. T: a map turned into a symlink is gone too.
|
|
408
|
+
# The exact line is matched, because the pathspec also matches files under a FOLDER of that name.
|
|
409
|
+
deleted=""
|
|
410
|
+
[ "$base_ok" = 1 ] && deleted="$(git diff --name-only --no-renames --diff-filter=DT "$RANGE" -- "$MAP")"
|
|
411
|
+
nl='
|
|
412
|
+
'
|
|
413
|
+
case "${nl}${deleted}${nl}" in *"${nl}${MAP}${nl}"*)
|
|
414
|
+
echo "WARN [risk-map] map-edited ${MAP}: this change deletes the risk map, which decides how much review later changes need"
|
|
415
|
+
echo "PASS [risk-map]: 1 warning(s) — advisory, never blocks a merge."
|
|
416
|
+
exit 0 ;;
|
|
417
|
+
esac
|
|
418
|
+
echo "note [risk-map]: this repo has no ${MAP} — no directory has a risk level yet."
|
|
419
|
+
echo " -> From the Product: \`yad risk-map draft <repo>\`, then let yad-connect-repos classify it, and commit it here."
|
|
420
|
+
echo "PASS [risk-map]: advisory — nothing to check."
|
|
421
|
+
exit 0
|
|
422
|
+
fi
|
|
423
|
+
|
|
424
|
+
# -z, then NUL to newline: git prints each path exactly as it is, never quoted or escaped (quotePath
|
|
425
|
+
# only stops escaping non-ASCII — a `"`, `\` or tab would still arrive wrapped in quotes). awk reads the
|
|
426
|
+
# lines; macOS awk cannot split records on NUL itself.
|
|
427
|
+
git ls-files -z | tr '\0' '\n' > "$tmp/files"
|
|
428
|
+
# The base is advisory here, so an unresolvable one does not fail the check the way it fails the
|
|
429
|
+
# blocking gates: the map's own lines are still checked, and the per-change warnings are skipped.
|
|
430
|
+
if [ "$base_ok" = 1 ]; then
|
|
431
|
+
# T: a file replaced by a symlink (or back) is an edit too — the map included.
|
|
432
|
+
git diff --name-only -z --diff-filter=ACMRT "$RANGE" | tr '\0' '\n' > "$tmp/changed"
|
|
433
|
+
else
|
|
434
|
+
echo "note [risk-map]: base ref '${BASE}' not found, or shares no history with HEAD (a shallow clone?) — checking the map's own lines only, not this change."
|
|
435
|
+
: > "$tmp/changed"
|
|
436
|
+
fi
|
|
437
|
+
|
|
438
|
+
awk -v mapf="$MAP" -v filesf="$tmp/files" -v changedf="$tmp/changed" "$RISK_MAP_AWK" "$MAP" "$tmp/files" "$tmp/changed"
|