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.
Files changed (156) hide show
  1. package/CHANGELOG.md +355 -0
  2. package/README.md +79 -26
  3. package/bin/commands.mjs +41 -0
  4. package/bin/yad.mjs +437 -124
  5. package/cli/artifact-status.mjs +34 -15
  6. package/cli/checkpoint.mjs +69 -49
  7. package/cli/codeowners-command.mjs +170 -0
  8. package/cli/codeowners.mjs +397 -0
  9. package/cli/commit.mjs +13 -9
  10. package/cli/companion.mjs +2 -2
  11. package/cli/dial.mjs +183 -0
  12. package/cli/docs.mjs +88 -32
  13. package/cli/doctor.mjs +1472 -97
  14. package/cli/epic-state.mjs +3478 -232
  15. package/cli/epic.mjs +506 -0
  16. package/cli/errors.mjs +4 -1
  17. package/cli/gate.mjs +1002 -209
  18. package/cli/history.mjs +556 -0
  19. package/cli/hook.mjs +266 -55
  20. package/cli/hubcommit.mjs +6 -17
  21. package/cli/index-command.mjs +87 -0
  22. package/cli/ledger.mjs +57 -7
  23. package/cli/lib.mjs +184 -18
  24. package/cli/manifest.mjs +367 -56
  25. package/cli/migrate.mjs +726 -53
  26. package/cli/mode.mjs +170 -0
  27. package/cli/next.mjs +349 -90
  28. package/cli/openpr.mjs +191 -39
  29. package/cli/people.mjs +654 -0
  30. package/cli/plan.mjs +417 -132
  31. package/cli/platform.mjs +110 -129
  32. package/cli/product-index.mjs +287 -0
  33. package/cli/protection.mjs +706 -0
  34. package/cli/reconcile.mjs +38 -12
  35. package/cli/repo-publish.mjs +24 -26
  36. package/cli/repo.mjs +23 -14
  37. package/cli/report.mjs +21 -15
  38. package/cli/review.mjs +24 -27
  39. package/cli/riskmap-command.mjs +289 -0
  40. package/cli/riskmap.mjs +373 -0
  41. package/cli/setup.mjs +139 -287
  42. package/cli/ship.mjs +7 -6
  43. package/cli/skill.mjs +180 -0
  44. package/cli/skip.mjs +211 -30
  45. package/cli/thread.mjs +42 -17
  46. package/cli/tidy.mjs +20 -20
  47. package/cli/update-commit.mjs +22 -22
  48. package/cli/usage.mjs +115 -109
  49. package/package.json +3 -3
  50. package/skills/sdlc/config.yaml +166 -87
  51. package/skills/sdlc/module-help.csv +35 -35
  52. package/skills/yad-analysis/SKILL.md +125 -65
  53. package/skills/yad-architecture/SKILL.md +34 -23
  54. package/skills/yad-architecture/references/contract-format.md +10 -8
  55. package/skills/yad-backfill/SKILL.md +14 -8
  56. package/skills/yad-backfill/references/backfill.md +1 -1
  57. package/skills/yad-change/SKILL.md +127 -52
  58. package/skills/yad-change/references/triage.md +42 -28
  59. package/skills/yad-checks/SKILL.md +89 -45
  60. package/skills/yad-checks/references/check-gates.md +315 -92
  61. package/skills/yad-checks/templates/checks/build-test-lint.sh +25 -7
  62. package/skills/yad-checks/templates/checks/commit-message.sh +17 -3
  63. package/skills/yad-checks/templates/checks/contract-check.sh +58 -2
  64. package/skills/yad-checks/templates/checks/epic-open.sh +3 -3
  65. package/skills/yad-checks/templates/checks/install-deps.sh +46 -0
  66. package/skills/yad-checks/templates/checks/ledger-guard.sh +94 -18
  67. package/skills/yad-checks/templates/checks/lineage-check.sh +23 -9
  68. package/skills/yad-checks/templates/checks/package-manager.sh +140 -0
  69. package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +4 -4
  70. package/skills/yad-checks/templates/checks/risk-map-check.sh +438 -0
  71. package/skills/yad-checks/templates/checks/verified-commits.sh +20 -46
  72. package/skills/yad-checks/templates/github/yad-checks.yml +37 -5
  73. package/skills/yad-checks/templates/github/yad-hub-checks.yml +5 -5
  74. package/skills/yad-checks/templates/github/yad-update-guard.yml +3 -4
  75. package/skills/yad-checks/templates/github/yad-verified-commits.yml +4 -4
  76. package/skills/yad-checks/templates/gitlab/.gitlab-ci.yml +7 -1
  77. package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +22 -4
  78. package/skills/yad-checks/templates/gitlab/yad-hub-checks.gitlab-ci.yml +5 -5
  79. package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +4 -4
  80. package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +91 -0
  81. package/skills/yad-checks/templates/hooks/ledger-guard.sh +38 -7
  82. package/skills/yad-commit/SKILL.md +6 -6
  83. package/skills/yad-connect-design/SKILL.md +6 -6
  84. package/skills/yad-connect-design/references/design-context.md +1 -1
  85. package/skills/yad-connect-design/references/design-registry.md +2 -2
  86. package/skills/yad-connect-docs/SKILL.md +12 -12
  87. package/skills/yad-connect-docs/references/docs-registry.md +1 -1
  88. package/skills/yad-connect-learning/SKILL.md +5 -5
  89. package/skills/yad-connect-learning/references/learning-registry.md +2 -2
  90. package/skills/yad-connect-repos/SKILL.md +92 -54
  91. package/skills/yad-connect-repos/references/code-context.md +6 -6
  92. package/skills/yad-connect-repos/references/hub-config.md +68 -58
  93. package/skills/yad-connect-repos/references/repos-registry.md +10 -9
  94. package/skills/yad-connect-repos/references/risk-map.md +81 -0
  95. package/skills/yad-connect-testing/SKILL.md +6 -6
  96. package/skills/yad-connect-testing/references/testing-context.md +3 -4
  97. package/skills/yad-connect-testing/references/testing-registry.md +2 -2
  98. package/skills/yad-defects/SKILL.md +8 -8
  99. package/skills/yad-discovery/SKILL.md +130 -94
  100. package/skills/yad-discovery/references/discovery-schema.md +23 -7
  101. package/skills/yad-discovery/references/foundation-schema.md +374 -0
  102. package/skills/yad-docs/SKILL.md +16 -11
  103. package/skills/yad-docs/references/data-mapping.md +9 -7
  104. package/skills/yad-docs/templates/app/package-lock.json +3 -3
  105. package/skills/yad-docs-overview/SKILL.md +32 -17
  106. package/skills/yad-docs-overview/references/pipeline-model.md +47 -28
  107. package/skills/yad-docs-sync/SKILL.md +10 -5
  108. package/skills/yad-docs-sync/references/staleness.md +8 -7
  109. package/skills/yad-engineer-review/SKILL.md +88 -24
  110. package/skills/yad-engineer-review/references/ship-and-record.md +25 -16
  111. package/skills/yad-epic/SKILL.md +178 -100
  112. package/skills/yad-epic/references/state-schema.md +626 -117
  113. package/skills/yad-hub-bridge/SKILL.md +66 -48
  114. package/skills/yad-hub-bridge/references/bridge.md +110 -83
  115. package/skills/yad-hub-bridge/references/login-roster.md +163 -70
  116. package/skills/yad-hub-bridge/templates/checks/hub-route.sh +22 -19
  117. package/skills/yad-hub-bridge/templates/github/yad-gate-sync.yml +34 -14
  118. package/skills/yad-hub-bridge/templates/gitlab/gitlab-ci.include-root.yml +2 -2
  119. package/skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml +22 -12
  120. package/skills/yad-implement/SKILL.md +29 -15
  121. package/skills/yad-implement/references/implement-conventions.md +2 -2
  122. package/skills/yad-learn/SKILL.md +9 -9
  123. package/skills/yad-learn/references/learning-state.md +2 -2
  124. package/skills/yad-open-pr/SKILL.md +64 -29
  125. package/skills/yad-pair-review/SKILL.md +18 -16
  126. package/skills/yad-pair-review/references/session-state.md +4 -4
  127. package/skills/yad-pr-template/SKILL.md +48 -27
  128. package/skills/yad-pr-template/references/risk-routing.md +97 -24
  129. package/skills/yad-pr-template/templates/checks/pr-template.sh +37 -15
  130. package/skills/yad-pr-template/templates/checks/pr-title.sh +27 -13
  131. package/skills/yad-pr-template/templates/checks/risk-route.sh +107 -14
  132. package/skills/yad-pr-template/templates/github/pull_request_template.md +7 -5
  133. package/skills/yad-pr-template/templates/gitlab/merge_request_templates/Default.md +7 -5
  134. package/skills/yad-pr-template/templates/hub/github/pull_request_template.md +15 -14
  135. package/skills/yad-pr-template/templates/hub/gitlab/merge_request_templates/Default.md +15 -13
  136. package/skills/yad-reconcile/SKILL.md +3 -3
  137. package/skills/yad-report/SKILL.md +5 -5
  138. package/skills/yad-review-companion/SKILL.md +12 -9
  139. package/skills/yad-review-gate/SKILL.md +198 -79
  140. package/skills/yad-review-gate/references/gating.md +230 -54
  141. package/skills/yad-run/SKILL.md +86 -56
  142. package/skills/yad-run/references/run-loop.md +67 -45
  143. package/skills/yad-ship/SKILL.md +18 -14
  144. package/skills/yad-spec/SKILL.md +31 -17
  145. package/skills/yad-spec/references/spec-handoff.md +17 -5
  146. package/skills/yad-status/SKILL.md +114 -56
  147. package/skills/yad-stories/SKILL.md +42 -27
  148. package/skills/yad-stories/references/story-schema.md +10 -9
  149. package/skills/yad-stub/SKILL.md +59 -48
  150. package/skills/yad-sync-repos/SKILL.md +3 -3
  151. package/skills/yad-test-cases/SKILL.md +37 -30
  152. package/skills/yad-test-cases/references/test-cases-schema.md +8 -5
  153. package/skills/yad-timeline/SKILL.md +8 -7
  154. package/skills/yad-ui/SKILL.md +46 -25
  155. package/cli/roster.mjs +0 -164
  156. 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 front gates approve
3
- # (ship-first), but it opens a reconcile-debt.json entry: the front artifacts do not yet describe what
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: hub artifacts (epic.md, stories/*.md) stay bounded to their first block.
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 front artifacts + add the regression test, then mark 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"