forgedock 1.26.0 → 1.28.0

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 (137) hide show
  1. package/README.md +4 -1
  2. package/bin/bootstrap.mjs +55 -0
  3. package/bin/engine/outcome-codes.mjs +13 -0
  4. package/bin/engine/phases.mjs +38 -2
  5. package/bin/engine/telemetry-seam.mjs +41 -6
  6. package/bin/engine/telemetry-seam.test.mjs +35 -0
  7. package/bin/engine-cli.mjs +616 -54
  8. package/bin/engine.mjs +13 -5
  9. package/bin/forgedock.mjs +169 -39
  10. package/bin/gc.mjs +580 -0
  11. package/bin/init-detect.mjs +68 -3
  12. package/bin/init-enrich.mjs +3 -0
  13. package/bin/init-repo.mjs +213 -0
  14. package/bin/journey.mjs +278 -43
  15. package/bin/lean/admission.mjs +33 -3
  16. package/bin/lean/batch.mjs +24 -5
  17. package/bin/lean/batch.test.mjs +54 -4
  18. package/bin/lean/checks.mjs +199 -0
  19. package/bin/lean/checks.test.mjs +183 -0
  20. package/bin/lean/conformance.mjs +235 -37
  21. package/bin/lean/conformance.test.mjs +143 -5
  22. package/bin/lean/credentials.mjs +29 -0
  23. package/bin/lean/depcache.mjs +312 -0
  24. package/bin/lean/depcache.test.mjs +298 -0
  25. package/bin/lean/drift.mjs +198 -0
  26. package/bin/lean/drift.test.mjs +223 -0
  27. package/bin/lean/effects.mjs +178 -16
  28. package/bin/lean/execution.e2e.test.mjs +81 -0
  29. package/bin/lean/execution.mjs +431 -0
  30. package/bin/lean/execution.test.mjs +583 -0
  31. package/bin/lean/heavy.mjs +289 -0
  32. package/bin/lean/heavy.test.mjs +207 -0
  33. package/bin/lean/lean.mjs +877 -151
  34. package/bin/lean/lean.test.mjs +1798 -28
  35. package/bin/lean/ledger.mjs +92 -0
  36. package/bin/lean/ledger.test.mjs +100 -0
  37. package/bin/lean/limits.mjs +58 -0
  38. package/bin/lean/limits.test.mjs +150 -0
  39. package/bin/lean/orchestrate.mjs +321 -19
  40. package/bin/lean/prompts.mjs +125 -17
  41. package/bin/lean/refactor.mjs +276 -0
  42. package/bin/lean/refactor.test.mjs +359 -0
  43. package/bin/lean/render.mjs +25 -6
  44. package/bin/lean/review-trigger.mjs +134 -0
  45. package/bin/lean/review-trigger.test.mjs +104 -0
  46. package/bin/lean/sandbox-provider.contract.test.mjs +257 -0
  47. package/bin/lean/sandbox-remote.mjs +142 -0
  48. package/bin/lean/sched.mjs +221 -0
  49. package/bin/lean/sched.test.mjs +310 -0
  50. package/bin/lean/schedd.mjs +247 -0
  51. package/bin/lean/schedd.test.mjs +180 -0
  52. package/bin/lean/toolchain.mjs +228 -0
  53. package/bin/lean/toolchain.test.mjs +154 -0
  54. package/bin/lean/verify.mjs +124 -0
  55. package/bin/lean/verify.test.mjs +118 -0
  56. package/bin/lean/worktree.mjs +69 -0
  57. package/bin/lean/worktree.test.mjs +141 -0
  58. package/bin/runner.mjs +53 -10
  59. package/bin/stats/github-source.mjs +1 -1
  60. package/bin/stats/index.mjs +14 -2
  61. package/bin/stats/query.mjs +72 -0
  62. package/bin/stats/stats.test.mjs +54 -0
  63. package/commands/adopt.md +3 -0
  64. package/commands/audit.md +5 -8
  65. package/commands/autopilot.md +2 -0
  66. package/commands/bootstrap.md +35 -0
  67. package/commands/cleanup.md +7 -22
  68. package/commands/forge-stats.md +2 -1
  69. package/commands/forgedock-init.md +125 -6
  70. package/commands/issue.md +89 -830
  71. package/commands/milestone.md +55 -6
  72. package/commands/orchestrate.md +10 -2
  73. package/commands/review-pr.md +2 -2
  74. package/commands/upgrade-deps.md +3 -3
  75. package/commands/work-on/build/validate.md +4 -0
  76. package/commands/work-on/close.md +2 -0
  77. package/commands/work-on/decompose.md +24 -6
  78. package/docs/CI.md +3 -3
  79. package/docs/CONFIG.md +227 -3
  80. package/docs/OUTCOME-CODES.md +11 -0
  81. package/docs/TELEMETRY.md +1 -1
  82. package/docs/site/command-reference.md +22 -0
  83. package/docs/site/getting-started.md +2 -0
  84. package/docs/site/start-a-new-project.md +327 -0
  85. package/package.json +1 -1
  86. package/scripts/affected-files-check.sh +91 -0
  87. package/scripts/affected-files-check.test.sh +80 -0
  88. package/scripts/bootstrap-create.mjs +393 -0
  89. package/scripts/bootstrap-validate.mjs +321 -0
  90. package/scripts/calibration.mjs +116 -0
  91. package/scripts/check-devdocs-freshness.sh +125 -0
  92. package/scripts/check-devdocs-freshness.test.sh +130 -0
  93. package/scripts/check-spec-markers.sh +3 -0
  94. package/scripts/check-spec-size.sh +29 -11
  95. package/scripts/check-spec-size.test.sh +27 -4
  96. package/scripts/ci-local/lib.mjs +8 -1
  97. package/scripts/ci-local/parallelism.test.mjs +27 -0
  98. package/scripts/ci-local/run.mjs +16 -5
  99. package/scripts/ci-local/scoping.mjs +82 -0
  100. package/scripts/ci-local.test.sh +14 -2
  101. package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
  102. package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
  103. package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
  104. package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
  105. package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
  106. package/scripts/fixtures/symbol-index/py/sample.py +41 -0
  107. package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
  108. package/scripts/fixtures/symbol-move/gen_big.py +201 -0
  109. package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
  110. package/scripts/forge-root.test.sh +1 -1
  111. package/scripts/hotspots.sh +100 -0
  112. package/scripts/hotspots.test.sh +101 -0
  113. package/scripts/issue-create.sh +197 -0
  114. package/scripts/issue-create.test.sh +166 -0
  115. package/scripts/maintenance-scan.sh +185 -0
  116. package/scripts/maintenance-scan.test.sh +89 -0
  117. package/scripts/milestone-drift.sh +206 -0
  118. package/scripts/milestone-drift.test.sh +120 -0
  119. package/scripts/northstar-docs.test.sh +68 -0
  120. package/scripts/refactor-verify.sh +368 -0
  121. package/scripts/refactor-verify.test.sh +202 -0
  122. package/scripts/review-prepare.sh +11 -9
  123. package/scripts/review-prepare.test.sh +14 -0
  124. package/scripts/structure-delta.sh +155 -0
  125. package/scripts/structure-delta.test.sh +136 -0
  126. package/scripts/symbol-index.mjs +253 -0
  127. package/scripts/symbol-index.py +354 -0
  128. package/scripts/symbol-index.test.sh +119 -0
  129. package/scripts/symbol-move-ts.test.sh +161 -0
  130. package/scripts/symbol-move.mjs +379 -0
  131. package/scripts/symbol-move.py +383 -0
  132. package/scripts/symbol-move.test.sh +168 -0
  133. package/templates/bootstrap/interview.md +34 -0
  134. package/templates/bootstrap/issue-body.md +42 -0
  135. package/templates/bootstrap/needs-human-issue.md +45 -0
  136. package/templates/bootstrap/stack-profiles.md +34 -0
  137. package/templates/bootstrap/technical-spike.md +75 -0
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env bash
2
+ # check-devdocs-freshness.test.sh — fixture PRs for scripts/check-devdocs-freshness.sh (forge#4185).
3
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
4
+ # SPDX-License-Identifier: AGPL-3.0-or-later
5
+ set -uo pipefail
6
+ S="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/check-devdocs-freshness.sh"
7
+ PASS=0; FAILN=0
8
+ TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
9
+ bad() { FAILN=$((FAILN+1)); echo "FAIL: $1"; }
10
+
11
+ git init -q --bare "$TMP/origin.git"
12
+ git clone -q "$TMP/origin.git" "$TMP/w" 2>/dev/null
13
+ cd "$TMP/w" || exit 1
14
+ git config user.email t@example.com; git config user.name t
15
+ mkdir -p bin/lean scripts/fixtures devdocs/modules devdocs/decisions
16
+ printf 'export const x = 1;\n' > bin/lean/lean.mjs
17
+ printf 'test\n' > bin/lean/lean.test.mjs
18
+ printf '#!/bin/sh\necho foo\n' > scripts/foo.sh
19
+ printf 'fixture\n' > scripts/fixtures/f.txt
20
+ printf '# lean\n' > devdocs/modules/lean.md
21
+ printf '# runner\n' > devdocs/modules/runner.md
22
+ : > devdocs/decisions/.gitkeep
23
+ cat > devdocs/index.yaml <<'EOF'
24
+ version: 2
25
+ domains:
26
+ lean:
27
+ docs:
28
+ - path: modules/lean.md
29
+ modules:
30
+ - name: runner
31
+ glob: "bin/runner*"
32
+ path: modules/runner.md
33
+ authority: required
34
+
35
+ - name: lean
36
+ glob: "bin/lean/*"
37
+ path: modules/lean.md
38
+ authority: required
39
+
40
+ always_load: []
41
+ EOF
42
+ cat > NORTHSTAR.md <<'EOF'
43
+ # North Star
44
+
45
+ ## Non-negotiables
46
+
47
+ 1. Deterministic first.
48
+
49
+ ## Runtime map
50
+
51
+ | Path | Role |
52
+ |---|---|
53
+ | `bin/lean/*` | lean engine |
54
+
55
+ ## Anti-goals
56
+
57
+ - More agents.
58
+ EOF
59
+ git add -A; git commit -qm base; git branch -M staging; git push -q origin staging 2>/dev/null
60
+ git checkout -q -b feat
61
+
62
+ reset() { git reset -q --hard origin/staging; git clean -qfd; }
63
+ commit() { git add -A; git commit -qm change; }
64
+ run() { # name want_rc [env...]
65
+ local name="$1" want="$2"; shift 2
66
+ env PR_BODY="" "$@" bash "$S" origin/staging >"$TMP/out" 2>&1
67
+ local rc=$?
68
+ if [ "$rc" = "$want" ]; then PASS=$((PASS+1)); else bad "$name: rc=$rc want=$want"; cat "$TMP/out"; fi
69
+ }
70
+
71
+ reset; printf 'export const x = 2;\n' > bin/lean/lean.mjs; commit
72
+ run "bin/lean/lean.mjs without docs fails" 1
73
+ grep -q 'bin/lean/lean.mjs' "$TMP/out" || bad "failure output names bin/lean/lean.mjs"
74
+
75
+ reset; printf 'export const x = 2;\n' > bin/lean/lean.mjs; printf '# lean\nnew entry\n' > devdocs/modules/lean.md; commit
76
+ run "bin/lean/lean.mjs + dossier passes" 0
77
+
78
+ reset; printf 'export const x = 2;\n' > bin/lean/lean.mjs; printf '# runner\nedit\n' > devdocs/modules/runner.md; commit
79
+ run "bin/lean/lean.mjs + unrelated dossier fails" 1
80
+
81
+ reset; printf 'export const x = 2;\n' > bin/lean/lean.mjs; commit
82
+ run "exemption line passes" 0 PR_BODY="Summary
83
+ docs-not-needed: refactor only, no behaviour change"
84
+ run "CRLF exemption line passes" 0 PR_BODY="$(printf 'Summary\r\ndocs-not-needed: typo fix\r\n')"
85
+ run "empty exemption reason fails" 1 PR_BODY="docs-not-needed: "
86
+ run "inline mention is not an exemption" 1 PR_BODY="we said docs-not-needed: no"
87
+ printf 'docs-not-needed: via file\n' > "$TMP/body"
88
+ env PR_BODY="" bash "$S" origin/staging --body-file "$TMP/body" >"$TMP/out" 2>&1 && PASS=$((PASS+1)) || bad "--body-file exemption passes"
89
+
90
+ reset; printf 'test2\n' > bin/lean/lean.test.mjs; printf 'fixture2\n' > scripts/fixtures/f.txt; printf 'x\n' > scripts/new.test.sh; commit
91
+ run "test-only and fixture changes pass" 0
92
+
93
+ reset; printf '#!/bin/sh\necho bar\n' > scripts/foo.sh; commit
94
+ run "scripts/foo.sh without docs fails" 1
95
+
96
+ reset; printf '#!/bin/sh\necho bar\n' > scripts/foo.sh; printf '# ADR\n' > devdocs/decisions/4185-x.md; commit
97
+ run "scripts/foo.sh + new decision passes" 0
98
+
99
+ reset; printf '# ADR\n' > devdocs/decisions/4185-x.md; git add -A; git commit -qm adr; git push -q origin HEAD:staging 2>/dev/null
100
+ git fetch -q origin; reset; printf '#!/bin/sh\necho bar\n' > scripts/foo.sh; printf '# ADR\nedited\n' > devdocs/decisions/4185-x.md; commit
101
+ run "editing an existing decision does not count" 1
102
+
103
+ reset; printf '#!/bin/sh\necho bar\n' > scripts/foo.sh; sed -i.bak 's/Deterministic first./Deterministic first, always./' NORTHSTAR.md; rm -f NORTHSTAR.md.bak; commit
104
+ run "NORTHSTAR edit outside Runtime map fails" 1
105
+
106
+ reset; printf '#!/bin/sh\necho bar\n' > scripts/foo.sh; sed -i.bak 's/| lean engine |/| lean engine (canonical) |/' NORTHSTAR.md; rm -f NORTHSTAR.md.bak; commit
107
+ run "NORTHSTAR edit inside Runtime map passes" 0
108
+
109
+ reset; printf '#!/bin/sh\necho bar\n' > scripts/foo.sh; sed -i.bak '/| `bin\/lean\/\*` | lean engine |/d' NORTHSTAR.md; rm -f NORTHSTAR.md.bak; commit
110
+ run "NORTHSTAR row deleted inside Runtime map passes" 0
111
+
112
+ reset; printf 'readme\n' > README.md; commit
113
+ run "no relevant changes pass" 0
114
+
115
+ reset
116
+ env PR_BODY="" bash "$S" no/such-ref >"$TMP/out" 2>&1; [ $? = 2 ] && PASS=$((PASS+1)) || bad "unknown base ref exits 2"
117
+ env bash "$S" >"$TMP/out" 2>&1; [ $? = 2 ] && PASS=$((PASS+1)) || bad "missing base ref and no upstream exits 2"
118
+
119
+ # #4220: without a base ref the branch's upstream is the base (CI always passes it explicitly).
120
+ git branch -q --set-upstream-to=origin/staging
121
+ printf '#!/bin/sh\necho bar\n' > scripts/foo.sh; commit
122
+ env PR_BODY="" bash "$S" >"$TMP/out" 2>&1; [ $? = 1 ] && PASS=$((PASS+1)) || bad "no base ref: upstream diff, docs missing fails"
123
+ printf 'docs-not-needed: fixture\n' > "$TMP/body"
124
+ env PR_BODY="" bash "$S" --body-file "$TMP/body" >"$TMP/out" 2>&1; [ $? = 0 ] && PASS=$((PASS+1)) || bad "no base ref + --body-file uses the upstream"
125
+ printf '# ADR\n' > devdocs/decisions/4220-x.md; commit
126
+ env PR_BODY="" bash "$S" >"$TMP/out" 2>&1; [ $? = 0 ] && PASS=$((PASS+1)) || bad "no base ref: upstream diff with a new decision passes"
127
+ git branch -q --unset-upstream
128
+
129
+ echo "check-devdocs-freshness.test.sh: $PASS passed, $FAILN failed"
130
+ [ "$FAILN" = 0 ]
@@ -91,7 +91,9 @@ MARKER_REGISTRY="
91
91
  BENCH_SCORECARD
92
92
  BLAST_RADIUS
93
93
  BLOCKED_ON_HUMAN_MERGE
94
+ BOOTSTRAP_PLAN
94
95
  BODY
96
+ BOOTSTRAP_PLAN
95
97
  BUILDER
96
98
  BUILD_BLOCKED
97
99
  CALIBRATION_CHECK
@@ -106,6 +108,7 @@ MARKER_REGISTRY="
106
108
  COORD_ISSUE
107
109
  CRITIQUE
108
110
  DECISION_RECORD
111
+ BOOTSTRAP_PLAN
109
112
  DECOMPOSED
110
113
  DECOMPOSE_BLOCKED
111
114
  DEGRADED_REREVIEW
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bash
2
- # check-spec-size.sh — PR spec-size budget (forge#3762).
2
+ # check-spec-size.sh — PR spec-size budget for every command spec (forge#3762, #4164).
3
3
  # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
4
4
  # SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  #
@@ -13,7 +13,9 @@
13
13
  # Exit: 0 pass/exempt, 1 budget violation, 2 usage/base-ref error (fail closed)
14
14
  #
15
15
  # Rules (against the merge-base of HEAD and the PR base, compared in bytes):
16
- # 1. Budgeted specs may not grow by more than 2% or more than 4096 bytes.
16
+ # 1. Every commands/**/*.md spec (head files plus files deleted at head) may not grow by more
17
+ # than 2% or more than 4096 bytes. A new spec (absent at the base) may be at most 4096 bytes.
18
+ # CEILINGS adds a per-file hard size cap (path:max_bytes) checked whatever the growth.
17
19
  # 2. The NET count of canonical block markers across all of commands/**/*.md
18
20
  # may not rise (moving a block between files, or replacing one with a stub,
19
21
  # is allowed). Count-based until the shared helper lands (#3493).
@@ -25,8 +27,9 @@ set -uo pipefail
25
27
 
26
28
  BASE="${1:-${BASE_REF:-origin/staging}}"
27
29
  PCT_NUM=2 # 2%
28
- ABS_MAX=4096 # bytes
29
- BUDGETED="commands/review-pr.md commands/review-pr-staging.md commands/work-on/remediate.md commands/work-on/review.md commands/work-on.md"
30
+ ABS_MAX=4096 # bytes; also the size cap for a new spec
31
+ # Per-file hard ceilings, one path:max_bytes per line (a spec converted to a script stub stays small).
32
+ CEILINGS="commands/issue.md:15360"
30
33
 
31
34
  SUMMARY="${GITHUB_STEP_SUMMARY:-/dev/stdout}"
32
35
  out() { printf '%s\n' "$*" >> "$SUMMARY"; }
@@ -67,27 +70,42 @@ out "## Spec-size budget (base: \`$BASE\`)"
67
70
  out ""
68
71
  out "| File | Base | Head | Delta | Delta % | Status |"
69
72
  out "|------|-----:|-----:|------:|--------:|--------|"
70
- for f in $BUDGETED; do
71
- [ -f "$f" ] || [ "$(base_size "$f")" != 0 ] || continue
73
+ ceiling_of() { printf '%s\n' "$CEILINGS" | awk -F: -v f="$1" '$1 == f {print $2; exit}'; }
74
+ HEAD_FILES=$(git ls-files 'commands/*.md' | sort -u)
75
+ BASE_FILES=$(git ls-tree -r --name-only "$BASE" -- commands | grep -E '\.md$' | sort -u)
76
+ ALL_FILES=$(printf '%s\n%s\n' "$HEAD_FILES" "$BASE_FILES" | sort -u)
77
+ SPECS=0
78
+ for f in $ALL_FILES; do
79
+ [ -n "$f" ] || continue
80
+ SPECS=$((SPECS + 1))
72
81
  b=$(base_size "$f")
73
82
  h=0; [ -f "$f" ] && h=$(wc -c < "$f" | tr -d ' ')
74
83
  g=$((h - b))
84
+ cap=$(ceiling_of "$f")
75
85
  status="ok"
76
- if [ "$g" -gt 0 ] && { [ $((g * 100)) -gt $((b * PCT_NUM)) ] || [ "$g" -gt "$ABS_MAX" ]; }; then
77
- status="OVER BUDGET"; FAILURES=$((FAILURES + 1))
86
+ if [ "$b" -eq 0 ]; then
87
+ [ "$h" -gt "$ABS_MAX" ] && status="OVER BUDGET (new spec > $ABS_MAX bytes)"
88
+ elif [ "$g" -gt 0 ] && { [ $((g * 100)) -gt $((b * PCT_NUM)) ] || [ "$g" -gt "$ABS_MAX" ]; }; then
89
+ status="OVER BUDGET"
90
+ fi
91
+ if [ -n "$cap" ] && [ "$h" -gt "$cap" ]; then
92
+ status="OVER CEILING ($cap bytes)"
78
93
  fi
94
+ # Only changed or failing specs get a row; the table would otherwise list every spec.
95
+ [ "$g" -ne 0 ] || [ "$status" != "ok" ] || continue
96
+ [ "$status" = "ok" ] || FAILURES=$((FAILURES + 1))
79
97
  if [ "$b" -gt 0 ]; then
80
98
  p=$(awk -v g="$g" -v b="$b" 'BEGIN{printf "%+.2f%%", g*100/b}')
81
99
  else p="n/a"; fi
82
100
  out "| \`$f\` | $b | $h | $g | $p | $status |"
83
101
  done
102
+ out ""
103
+ out "$SPECS spec(s) budgeted; unchanged specs within budget are not listed."
84
104
 
85
105
  # Net marker total across every commands/**/*.md: head (working tree, incl. new
86
106
  # files) vs base (incl. files deleted at head). Fail only when the total rises.
87
107
  HEAD_TOTAL=0; BASE_TOTAL=0; MARKER_ROWS=""
88
- HEAD_FILES=$(git ls-files 'commands/*.md' | sort -u)
89
- BASE_FILES=$(git ls-tree -r --name-only "$BASE" -- commands | grep -E '\.md$' | sort -u)
90
- for f in $(printf '%s\n%s\n' "$HEAD_FILES" "$BASE_FILES" | sort -u); do
108
+ for f in $ALL_FILES; do
91
109
  [ -n "$f" ] || continue
92
110
  hc=0; [ -f "$f" ] && hc=$(grep -cE "$MARKERS" "$f" 2>/dev/null || true); hc=${hc:-0}
93
111
  bc=$(git show "$BASE:$f" 2>/dev/null | grep -cE "$MARKERS" 2>/dev/null || true); bc=${bc:-0}
@@ -45,8 +45,30 @@ run "over 4 KB fails" 1
45
45
  reset; gen 5 >> commands/review-pr.md # small growth under both limits
46
46
  run "small growth passes" 0
47
47
 
48
- reset; gen 5000 >> commands/other.md # not a budgeted spec
49
- run "non-budgeted growth passes" 0
48
+ # Every spec is budgeted (#4164): top-level and nested specs outside the old review list
49
+ reset; gen 5000 >> commands/other.md
50
+ run "formerly unbudgeted spec growth fails" 1; has "other spec flagged" "| \`commands/other.md\`"
51
+ reset; gen 1000 > commands/work-on/nested.md; git add -A; git commit -qm nested; git push -q origin HEAD:staging 2>/dev/null; git fetch -q origin staging
52
+ gen 100 >> commands/work-on/nested.md
53
+ run "nested spec over 2% fails" 1
54
+ git reset -q --hard HEAD~1; git push -q -f origin HEAD:staging 2>/dev/null; git fetch -q origin staging; reset
55
+ reset; gen 2 >> commands/other.md
56
+ run "unbudgeted-before small growth passes" 0
57
+
58
+ # New specs: up to 4096 bytes
59
+ reset; gen 100 > commands/brand-new.md; git add -A # ~2.8 KB
60
+ run "new spec under 4 KB passes" 0
61
+ reset; gen 200 > commands/brand-new.md; git add -A # ~5.6 KB
62
+ run "new spec over 4 KB fails" 1; has "new spec flagged" "new spec >"
63
+
64
+ # Per-file ceiling: commands/issue.md may not exceed 15360 bytes whatever the growth
65
+ reset; head -c 15300 /dev/zero | tr '\0' 'x' > commands/issue.md; git add -A; git commit -qm issue; git push -q origin HEAD:staging 2>/dev/null; git fetch -q origin staging
66
+ printf '%050d' 0 >> commands/issue.md # 15350 bytes: +0.3%, under the cap
67
+ run "ceiling: under the cap passes" 0
68
+ printf '%050d' 0 >> commands/issue.md # 15400 bytes: +0.65% (within 2%/4 KB) but over the cap
69
+ run "ceiling: over the cap fails" 1; has "ceiling flagged" "OVER CEILING (15360 bytes)"
70
+ run "ceiling: exempt label waives" 0 PR_LABELS="spec-size-exempt"
71
+ git reset -q --hard HEAD~1; git push -q -f origin HEAD:staging 2>/dev/null; git fetch -q origin staging; reset
50
72
 
51
73
  reset; gen 100 > commands/review-pr.md # shrink
52
74
  run "shrink passes" 0
@@ -89,9 +111,10 @@ run "copy without removal still fails" 1
89
111
  # Shared-helper stubs (forge#3778): the sourced-helper stub shape carries none of the markers, so converting an inlined block to a stub
90
112
  # is a net reduction, while re-pasting the full block still fails.
91
113
  STUB_LOC='type forge_resolve >/dev/null 2>&1 || for _r in "${FORGEDOCK_HOME:-}"; do . "$_r/scripts/lib/forge-resolve.sh"; done'
92
- reset; printf '%s\n' "$STUB_LOC" '. "$(forge_resolve lib/fence-bootstrap.sh)" || exit 1 # FENCE_BOOTSTRAP' 'HELPER_SCRIPT=$(forge_resolve x.sh)' >> commands/other.md
114
+ # (appended to the large work-on.md so the byte budget stays out of the marker cases)
115
+ reset; printf '%s\n' "$STUB_LOC" '. "$(forge_resolve lib/fence-bootstrap.sh)" || exit 1 # FENCE_BOOTSTRAP' 'HELPER_SCRIPT=$(forge_resolve x.sh)' >> commands/work-on.md
93
116
  run "one-tag-per-stub design passes" 0
94
- reset; : > commands/donor.md; printf '%s\n' "$STUB_LOC" 'HELPER_SCRIPT=$(forge_resolve x.sh)' >> commands/other.md
117
+ reset; : > commands/donor.md; printf '%s\n' "$STUB_LOC" 'HELPER_SCRIPT=$(forge_resolve x.sh)' >> commands/work-on.md
95
118
  run "block converted to a stub passes" 0
96
119
  reset; printf '%s\n' "$STUB_LOC" 'HELPER_SCRIPT=""' >> commands/other.md
97
120
  run "stub plus a re-pasted resolver block fails" 1
@@ -327,7 +327,7 @@ export function shellQuote(s) {
327
327
  // An allowlist, not a blacklist: remote CI starts from a clean environment, so ambient operator state (agent
328
328
  // credentials, install-root overrides, git hook variables such as GIT_DIR/GIT_INDEX_FILE, FORGE_*/CLAUDE_* settings)
329
329
  // must not reach the jobs. Toolchain variables are passed through; credential-looking names are always dropped.
330
- const ENV_EXACT = new Set(['PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'LANG', 'LANGUAGE', 'TERM', 'TMPDIR', 'TMP', 'TEMP', 'TZ', 'COLORTERM', 'NO_COLOR', 'SSL_CERT_FILE', 'SSL_CERT_DIR', 'NODE_EXTRA_CA_CERTS', 'REQUESTS_CA_BUNDLE', 'CURL_CA_BUNDLE', 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy', 'DOCKER_HOST', 'JAVA_HOME', 'GOPATH', 'GOROOT', 'GOFLAGS', 'GOCACHE', 'GOMODCACHE', 'GOPROXY', 'GOTOOLCHAIN', 'VIRTUAL_ENV', 'PYTHONPATH', 'PYTHONDONTWRITEBYTECODE', 'RUSTFLAGS', 'RUSTUP_HOME', 'SDKMAN_DIR', 'PNPM_HOME', 'BUN_INSTALL', 'NVM_DIR', 'NVM_BIN', 'NVM_INC', 'RBENV_ROOT', 'PYENV_ROOT', 'ASDF_DIR', 'ASDF_DATA_DIR', 'MISE_DATA_DIR', 'HOMEBREW_PREFIX', 'ComSpec', 'SystemRoot', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', 'PROGRAMFILES', 'MSYSTEM']);
330
+ const ENV_EXACT = new Set(['PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'LANG', 'LANGUAGE', 'TERM', 'TMPDIR', 'TMP', 'TEMP', 'TZ', 'COLORTERM', 'NO_COLOR', 'SSL_CERT_FILE', 'SSL_CERT_DIR', 'NODE_EXTRA_CA_CERTS', 'REQUESTS_CA_BUNDLE', 'CURL_CA_BUNDLE', 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy', 'DOCKER_HOST', 'JAVA_HOME', 'GOPATH', 'GOROOT', 'GOFLAGS', 'GOCACHE', 'GOMODCACHE', 'GOPROXY', 'GOTOOLCHAIN', 'VIRTUAL_ENV', 'PYTHONPATH', 'PYTHONDONTWRITEBYTECODE', 'RUSTFLAGS', 'RUSTUP_HOME', 'SDKMAN_DIR', 'PNPM_HOME', 'BUN_INSTALL', 'NVM_DIR', 'NVM_BIN', 'NVM_INC', 'RBENV_ROOT', 'PYENV_ROOT', 'ASDF_DIR', 'ASDF_DATA_DIR', 'MISE_DATA_DIR', 'HOMEBREW_PREFIX', 'ComSpec', 'SystemRoot', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', 'PROGRAMFILES', 'MSYSTEM', 'FORGE_HOST_PARALLELISM']);
331
331
  const ENV_PREFIX = ['LC_', 'XDG_', 'CARGO_', 'MAVEN_', 'M2_', 'GRADLE_', 'DOTNET_', 'npm_config_', 'NPM_CONFIG_', 'YARN_', 'UV_', 'POETRY_', 'PIP_CACHE', 'FORGE_CI_LOCAL_'];
332
332
  // Substring tokens are unambiguous; short ones (PASS, PAT, KEY, IDENT, CRED, SESSION, COOKIE) match only as whole
333
333
  // name segments (or a trailing _KEY) so benign names such as FORGE_CI_LOCAL_PASSTHROUGH or PATH are kept.
@@ -347,6 +347,13 @@ export function scrubbedEnv(extra = {}) {
347
347
  return Object.assign(env, extra);
348
348
  }
349
349
 
350
+ // Host-derived worker cap a ForgeDock lane exports (#4182): never run more jobs/slots than FORGE_HOST_PARALLELISM.
351
+ export function capParallel(n, env = process.env) {
352
+ const s = String(env?.FORGE_HOST_PARALLELISM ?? '').trim();
353
+ const p = /^\d+$/.test(s) ? Number(s) : 0;
354
+ return p > 0 ? Math.min(n, p) : n;
355
+ }
356
+
350
357
  export function homeForgeDir() {
351
358
  return process.env.FORGE_CI_LOCAL_HOME || path.join(process.env.HOME || os.homedir(), '.forge');
352
359
  }
@@ -0,0 +1,27 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ import { describe, it } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { scrubbedEnv, capParallel } from "./lib.mjs";
6
+
7
+ describe("ci-local host parallelism (#4182)", () => {
8
+ it("scrubbedEnv passes FORGE_HOST_PARALLELISM through and still drops secrets", () => {
9
+ const keep = { FORGE_HOST_PARALLELISM: process.env.FORGE_HOST_PARALLELISM, FD_TEST_API_TOKEN: process.env.FD_TEST_API_TOKEN, FORGE_OTHER_SETTING: process.env.FORGE_OTHER_SETTING };
10
+ process.env.FORGE_HOST_PARALLELISM = "2"; process.env.FD_TEST_API_TOKEN = "s3cret"; process.env.FORGE_OTHER_SETTING = "x";
11
+ try {
12
+ const env = scrubbedEnv({});
13
+ assert.equal(env.FORGE_HOST_PARALLELISM, "2");
14
+ assert.equal(env.FD_TEST_API_TOKEN, undefined);
15
+ assert.equal(env.FORGE_OTHER_SETTING, undefined, "other FORGE_* settings stay out");
16
+ } finally {
17
+ for (const [k, v] of Object.entries(keep)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; }
18
+ }
19
+ });
20
+
21
+ it("capParallel caps slots/job parallelism at a positive FORGE_HOST_PARALLELISM, else leaves them alone", () => {
22
+ assert.equal(capParallel(4, { FORGE_HOST_PARALLELISM: "2" }), 2);
23
+ assert.equal(capParallel(1, { FORGE_HOST_PARALLELISM: "2" }), 1);
24
+ assert.equal(capParallel(4, {}), 4);
25
+ for (const bad of ["0", "abc", "-1", "", " "]) assert.equal(capParallel(4, { FORGE_HOST_PARALLELISM: bad }), 4, JSON.stringify(bad));
26
+ });
27
+ });
@@ -11,7 +11,7 @@ import fs from 'node:fs';
11
11
  import net from 'node:net';
12
12
  import os from 'node:os';
13
13
  import path from 'node:path';
14
- import { run as runSync, commandExists, scrubbedEnv, homeForgeDir, sha256, fileHash, toolVersion, shellSplit } from './lib.mjs';
14
+ import { run as runSync, commandExists, scrubbedEnv, capParallel, homeForgeDir, sha256, fileHash, toolVersion, shellSplit } from './lib.mjs';
15
15
  import { shellArgv } from './adapters/common.mjs';
16
16
  import { checkPortability } from './adapters/portability.mjs';
17
17
 
@@ -25,7 +25,7 @@ for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(sig, () => { clean
25
25
  // ---- machine-wide slot semaphore ----
26
26
  function slotCount() {
27
27
  const n = Number(process.env.FORGE_CI_LOCAL_SLOTS);
28
- return n > 0 ? n : Math.max(2, Math.min(8, Math.floor(os.cpus().length / 2)));
28
+ return capParallel(n > 0 ? n : Math.max(2, Math.min(8, Math.floor(os.cpus().length / 2))));
29
29
  }
30
30
  function alive(pid) { try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; } }
31
31
 
@@ -117,6 +117,16 @@ class Tail {
117
117
  async function portFree(port) { return new Promise((res) => { const s = net.createServer(); s.once('error', () => res(false)); s.listen(port, '127.0.0.1', () => s.close(() => res(true))); }); }
118
118
  const HEALTH_FLAGS = new Set(['--health-cmd', '--health-interval', '--health-timeout', '--health-retries', '--health-start-period', '--shm-size']);
119
119
 
120
+ // forge#4181: ownership labels from the lane (FORGE_DOCKER_LABELS); a standalone run fills in defaults so it is labelled too.
121
+ const LABEL_KEYS = ['owner', 'batch', 'lane', 'issue', 'expires'];
122
+ export function dockerLabelArgs(env = process.env, now = Date.now()) {
123
+ const got = new Map();
124
+ for (const e of String(env.FORGE_DOCKER_LABELS || '').split(/\s+/)) { const m = /^forgedock\.([a-z]+)=([A-Za-z0-9_.-]+)$/.exec(e); if (m && !got.has(m[1])) got.set(m[1], m[2]); }
125
+ const dflt = { owner: '1', batch: 'adhoc', lane: `ci-local-${process.pid}`, issue: '0', expires: String(now + 6 * 3600 * 1000) };
126
+ for (const k of LABEL_KEYS) if (!got.has(k)) got.set(k, dflt[k]);
127
+ return [...got].flatMap(([k, v]) => ['--label', `forgedock.${k}=${v}`]);
128
+ }
129
+
120
130
  async function startServices(job, log) {
121
131
  const started = [];
122
132
  const stop = () => { for (const n of started) runSync('docker', ['rm', '-f', n], { timeout: 30000 }); started.length = 0; };
@@ -128,11 +138,12 @@ async function startServices(job, log) {
128
138
  if (!commandExists('docker') || runSync('docker', ['info'], { timeout: 20000 }).code !== 0) return { error: 'docker is not available', finish };
129
139
  // forge#4040 (SEC-6): one docker network per job; nothing runs with --network host.
130
140
  net = `forge-ci-net-${process.pid}-${job.id.replace(/[^A-Za-z0-9_.-]/g, '-')}`.slice(0, 120);
131
- const nr = runSync('docker', ['network', 'create', net], { timeout: 30000 });
141
+ const labels = dockerLabelArgs();
142
+ const nr = runSync('docker', ['network', 'create', ...labels, net], { timeout: 30000 });
132
143
  if (nr.code !== 0) { net = ''; return { error: `docker network create failed: ${(nr.err || nr.out).split('\n')[0]}`, finish }; }
133
144
  for (const svc of job.services) {
134
145
  const name = `forge-ci-${process.pid}-${job.id.replace(/[^A-Za-z0-9_.-]/g, '-')}-${svc.name}`.slice(0, 120);
135
- const args = ['run', '-d', '--rm', '--name', name, '--network', net, '--network-alias', svc.name];
146
+ const args = ['run', '-d', '--rm', ...labels, '--name', name, '--network', net, '--network-alias', svc.name];
136
147
  const hostPorts = [];
137
148
  for (const p of svc.ports || []) {
138
149
  const m = /^(?:(\d+):)?(\d+)(?:\/(tcp|udp))?$/.exec(String(p).trim());
@@ -361,7 +372,7 @@ export async function runPlan(plan, o = {}) {
361
372
  // (DEGRADED) and a re-run resumes from the result cache.
362
373
  const maxInvS = Number(process.env.FORGE_CI_LOCAL_MAX_INVOCATION_SECONDS) || 540;
363
374
  const budgetS = Math.min(requestedS, maxInvS); const capped = requestedS > maxInvS;
364
- const maxParallel = o.maxParallel || plan.config.max_parallel || Number(process.env.FORGE_CI_LOCAL_MAX_PARALLEL) || 4;
375
+ const maxParallel = capParallel(o.maxParallel || plan.config.max_parallel || Number(process.env.FORGE_CI_LOCAL_MAX_PARALLEL) || 4);
365
376
  const tmpRoot = fs.mkdtempSync(path.join(process.env.TMPDIR || os.tmpdir(), 'forge-ci-local.'));
366
377
  cleanups.add(() => { try { fs.rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ } });
367
378
  const ctx = {
@@ -342,6 +342,7 @@ export function scopeCommand(cmd, cwdRel, sctx, depth = 0) {
342
342
 
343
343
  let r = null;
344
344
  if (LINTERS[tool] && !(LINTERS[tool].sub && !LINTERS[tool].sub.has(rest[1]))) r = scopeLint(tool, rest, cmd, cwdRel, sctx, join);
345
+ else if (tool === 'tsc') r = scopeTsc(rest, cmd, cwdRel, sctx, join);
345
346
  else if (tool === 'jest') r = scopeJest(rest, cmd, cwdRel, sctx, join);
346
347
  else if (tool === 'vitest') r = scopeVitest(rest, cmd, cwdRel, sctx, join);
347
348
  else if (tool === 'pytest' || tool === 'py.test' || (/^python[0-9.]*$/.test(tool) && rest[1] === '-m' && rest[2] === 'pytest')) r = scopeRunner('pytest', tool === 'pytest' || tool === 'py.test' ? rest.slice(1) : rest.slice(3), tool === 'pytest' || tool === 'py.test' ? [rest[0]] : rest.slice(0, 3), cmd, cwdRel, sctx, join);
@@ -507,6 +508,87 @@ function scopeMaven(rest, cmd, cwdRel, sctx, join) {
507
508
  return scoped(join([...rest, '-pl', [...mods].sort().join(','), '-amd']), mods.size, null, 'maven-affected');
508
509
  }
509
510
 
511
+ // ---- TypeScript compiler: the owning tsconfig project(s) of the changed files and of the files that reference them ----
512
+ const TS_EXT = set('ts tsx mts cts js jsx mjs cjs');
513
+ const TSC_VALUE = set('-p --project -t --target -m --module --outDir --outFile --rootDir --lib --jsx --tsBuildInfoFile --declarationDir --moduleResolution --baseUrl --types --typeRoots --locale --generateTrace --maxNodeModuleJsDepth --moduleDetection --verbatimModuleSyntax');
514
+
515
+ // tsconfig.json is JSON with comments and trailing commas; null when unreadable.
516
+ function readTsconfig(sctx, dir) {
517
+ sctx.cache.tsconfig = sctx.cache.tsconfig || new Map();
518
+ if (sctx.cache.tsconfig.has(dir)) return sctx.cache.tsconfig.get(dir);
519
+ let cfg = null;
520
+ try {
521
+ const raw = fs.readFileSync(path.join(sctx.repo, dir, 'tsconfig.json'), 'utf8');
522
+ const noComments = raw.replace(/("(?:[^"\\]|\\.)*")|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (m, q) => q || '');
523
+ cfg = JSON.parse(noComments.replace(/,(\s*[}\]])/g, '$1'));
524
+ } catch { cfg = null; }
525
+ sctx.cache.tsconfig.set(dir, cfg);
526
+ return cfg;
527
+ }
528
+
529
+ // The directory of the nearest tsconfig.json above `file` ('.' for the repo root), or '' when none owns it.
530
+ export function tsProjectOf(sctx, file) {
531
+ for (let d = px.dirname(file); ; d = px.dirname(d)) {
532
+ if (sctx.present.has(d === '.' ? 'tsconfig.json' : `${d}/tsconfig.json`)) return d;
533
+ if (d === '.' || d === '/') return '';
534
+ }
535
+ }
536
+
537
+ // A project `tsc -b` can build: composite itself, or listed in another tsconfig's `references`.
538
+ function tsBuildable(sctx, dir) {
539
+ const cfg = readTsconfig(sctx, dir);
540
+ if (cfg && cfg.compilerOptions && cfg.compilerOptions.composite === true) return true;
541
+ if (!sctx.cache.tsRefs) {
542
+ sctx.cache.tsRefs = new Set();
543
+ for (const f of sctx.tracked) {
544
+ if (px.basename(f) !== 'tsconfig.json' || !sctx.present.has(f)) continue;
545
+ const from = px.dirname(f); const c = readTsconfig(sctx, from);
546
+ for (const r of (c && Array.isArray(c.references) ? c.references : [])) {
547
+ if (!r || typeof r.path !== 'string') continue;
548
+ const to = px.normalize(px.join(from, r.path));
549
+ sctx.cache.tsRefs.add(to.endsWith('.json') ? px.dirname(to) : to);
550
+ }
551
+ }
552
+ }
553
+ return sctx.cache.tsRefs.has(dir);
554
+ }
555
+
556
+ function scopeTsc(rest, cmd, cwdRel, sctx, join) {
557
+ const flags = []; let project = ''; let build = false; const buildTargets = [];
558
+ for (let i = 1; i < rest.length; i++) {
559
+ const t = rest[i];
560
+ if (t === '-p' || t === '--project') { if (i + 1 >= rest.length) return full(cmd, '', { selector: 'unscoped:tsc-args' }); project = rest[++i]; continue; }
561
+ if (t.startsWith('--project=')) { project = t.slice(10); continue; }
562
+ if (t === '-b' || t === '--build') { build = true; continue; }
563
+ if (t === '-w' || t === '--watch') return full(cmd, '', { selector: 'unscoped:tsc-watch' });
564
+ if (t.startsWith('-')) { flags.push(t); if (TSC_VALUE.has(t) && i + 1 < rest.length) flags.push(rest[++i]); continue; }
565
+ if (!build) return full(cmd, '', { selector: 'unscoped:tsc-explicit-files' });
566
+ buildTargets.push(t);
567
+ }
568
+ if (/[$`*?]/.test(project) || buildTargets.some((t) => /[$`*?]/.test(t))) return full(cmd, '', { selector: 'unscoped:complex-target' });
569
+ // The project this command checks: -p X, the single -b target, else the tsconfig in its cwd.
570
+ const targetOf = (t) => { const r = rel(cwdRel, t); return r.endsWith('.json') ? px.dirname(r) : r; };
571
+ if (buildTargets.length > 1) return full(cmd, '', { selector: 'unscoped:explicit-targets' });
572
+ const target = project ? targetOf(project) : buildTargets.length ? targetOf(buildTargets[0]) : rel(cwdRel, '.');
573
+ if (sctx.widen) return full(cmd, sctx.widen, { selector: 'tsc-project' });
574
+ const un = unmappedFile(sctx.changed, TS_EXT);
575
+ if (un) return full(cmd, `unknown-language: ${un}`, { selector: 'tsc-project' });
576
+ const r = reach(sctx);
577
+ if (r.widen) return full(cmd, r.widen, { selector: 'tsc-project' });
578
+ const tsFiles = [...r.files].filter((f) => TS_EXT.has(extOf(f)));
579
+ if (!tsFiles.length) return skipNo(0, null, 'tsc-project', 'no changed TS/JS file');
580
+ const owners = dedupe(tsFiles.map((f) => tsProjectOf(sctx, f)).filter(Boolean))
581
+ .filter((d) => target === '.' || d === target || d.startsWith(target + '/')).sort();
582
+ if (!owners.length) return skipNo(0, null, 'tsc-project', `no changed TS/JS file in ${target}`);
583
+ // A command already aimed at one non-root project is that project's check: keep it as is.
584
+ if (target !== '.') return scoped(cmd, owners.length, null, 'tsc-project', { projects: owners });
585
+ if (owners.includes('.')) return full(cmd, 'root-project', { selector: 'tsc-project', projects: owners });
586
+ if (owners.length === 1) return scoped(join([rest[0], build ? '-b' : '-p', fromCwd(cwdRel, owners[0]), ...flags]), 1, null, 'tsc-project', { projects: owners });
587
+ if (!owners.every((d) => tsBuildable(sctx, d))) return full(cmd, 'multi-project', { selector: 'tsc-project', projects: owners });
588
+ // Build mode rejects --noEmit; composite projects emit their declarations as the repo's own build does.
589
+ return scoped(join([rest[0], '-b', ...owners.map((d) => fromCwd(cwdRel, d)), ...flags.filter((f) => f !== '--noEmit')]), owners.length, null, 'tsc-project', { projects: owners });
590
+ }
591
+
510
592
  function scopeMono(tool, rest, cmd, sctx, join) {
511
593
  if (sctx.widen) return full(cmd, sctx.widen, { selector: `${tool}-affected` });
512
594
  const base = sctx.from || sctx.baseRef;
@@ -432,7 +432,7 @@ SHIM
432
432
  chmod +x "$T/bin/docker"
433
433
  export FAKE_DOCKER_LOG="$T/docker.log"
434
434
  : > "$FAKE_DOCKER_LOG"
435
- OUT=$(PATH="$T/bin:$PATH" bash "$S" run --repo "$r" --base main --base-branch main --head-branch feat 2>&1); RC=$?
435
+ OUT=$(env -u FORGE_DOCKER_LABELS PATH="$T/bin:$PATH" bash "$S" run --repo "$r" --base main --base-branch main --head-branch feat 2>&1); RC=$?
436
436
  eq "a service job runs when docker is available" 0 "$RC"
437
437
  LOG=$(cat "$FAKE_DOCKER_LOG")
438
438
  has "container is started with the same image" "$LOG" "postgres:16"
@@ -442,6 +442,18 @@ has "health options are passed through" "$LOG" "--health-cmd"
442
442
  hasnt "options off the safe list are dropped" "$LOG" "--privileged"
443
443
  has "container is named per run" "$LOG" "forge-ci-"
444
444
  has "container is torn down" "$LOG" "rm -f"
445
+ # forge#4181: the network and every container carry the forgedock.* ownership labels; standalone runs get defaults
446
+ RUNL=$(grep '^run -d' <<<"$LOG"); NETL=$(grep '^network create' <<<"$LOG")
447
+ for k in owner=1 batch=adhoc lane=ci-local- issue=0 expires=; do has "4181: container label forgedock.$k" "$RUNL" "--label forgedock.$k"; has "4181: network label forgedock.$k" "$NETL" "--label forgedock.$k"; done
448
+ has "4181: container keeps --rm" "$RUNL" "--rm"
449
+ : > "$FAKE_DOCKER_LOG"
450
+ OUT=$(FORGE_DOCKER_LABELS="forgedock.owner=1 forgedock.batch=b9 forgedock.lane=lean-42 forgedock.issue=42 forgedock.expires=1792434600394 other=x forgedock.bad=a;b" PATH="$T/bin:$PATH" bash "$S" run --repo "$r" --base main --base-branch main --head-branch feat 2>&1); RC=$?
451
+ eq "4181: a labelled run passes" 0 "$RC"
452
+ RUNL=$(grep '^run -d' "$FAKE_DOCKER_LOG"); NETL=$(grep '^network create' "$FAKE_DOCKER_LOG")
453
+ for l in batch=b9 lane=lean-42 issue=42 expires=1792434600394; do has "4181: container gets lane label $l" "$RUNL" "--label forgedock.$l"; has "4181: network gets lane label $l" "$NETL" "--label forgedock.$l"; done
454
+ hasnt "4181: non-forgedock entries are dropped" "$RUNL" "other=x"
455
+ hasnt "4181: malformed entries are dropped" "$RUNL" "forgedock.bad"
456
+ hasnt "4181: env labels replace the defaults" "$RUNL" "batch=adhoc"
445
457
  : > "$FAKE_DOCKER_LOG"
446
458
  OUT=$(FAKE_DOCKER_INFO_RC=1 PATH="$T/bin:$PATH" bash "$S" run --repo "$r" --base main --base-branch main --head-branch feat 2>&1); RC=$?
447
459
  eq "docker absent defers the service job (exit 0)" 0 "$RC"
@@ -776,7 +788,7 @@ OUT=$(PATH="$T/bin:$PATH" bash "$S" run --repo "$r" --base main --base-branch ma
776
788
  LOG=$(cat "$FAKE_DOCKER_LOG")
777
789
  eq "SEC-6: the circleci service job runs" 0 "$RC"
778
790
  hasnt "SEC-6: no --network host" "$LOG" "--network host"
779
- has "SEC-6: a per-job docker network is created" "$LOG" "network create forge-ci-net-"
791
+ has "SEC-6: a per-job docker network is created" "$(grep '^network create' <<<"$LOG")" "forge-ci-net-"
780
792
  has "SEC-6: the container joins it" "$LOG" "--network forge-ci-net-"
781
793
  has "SEC-6: only the declared/default port is published, on 127.0.0.1" "$LOG" "-p 127.0.0.1:54331:5432"
782
794
  has "SEC-6: the network is removed afterwards" "$LOG" "network rm forge-ci-net-"
@@ -0,0 +1,3 @@
1
+ def greet(name)
2
+ "hi #{name}"
3
+ end
@@ -0,0 +1 @@
1
+ from .c import value
@@ -0,0 +1,5 @@
1
+ from pkg import b
2
+
3
+
4
+ def run():
5
+ return b.step()
@@ -0,0 +1,14 @@
1
+ from typing import TYPE_CHECKING
2
+
3
+ from . import c
4
+
5
+ if TYPE_CHECKING:
6
+ from . import a
7
+
8
+
9
+ def step():
10
+ from .a import run # function-local: not a module-level edge
11
+ return c.value + (0 if run else 1)
12
+
13
+
14
+ from .a import run as _run_alias # noqa: E402 module-level a <-> b cycle
@@ -0,0 +1 @@
1
+ value = 1
@@ -0,0 +1,41 @@
1
+ """Fixture module for symbol-index.test.sh. SECRET_DOCSTRING_MARKER"""
2
+ import os
3
+ from collections import OrderedDict as OD
4
+
5
+ __all__ = ["Greeter", "greet", "fetch", "LIMIT"]
6
+
7
+ LIMIT = 10
8
+ _cache = OD()
9
+
10
+
11
+ def _normalise(name):
12
+ return name.strip().lower()
13
+
14
+
15
+ def greet(name):
16
+ return "hi " + _normalise(name)
17
+
18
+
19
+ async def fetch(key):
20
+ return _cache.get(key, LIMIT)
21
+
22
+
23
+ def helper():
24
+ return os.getcwd()
25
+
26
+
27
+ class Greeter:
28
+ """SECRET_CLASS_DOC_MARKER"""
29
+
30
+ def __init__(self, name):
31
+ self.name = name
32
+
33
+ @property
34
+ def text(self):
35
+ return greet(self.name)
36
+
37
+ def _shout(self):
38
+ return self.text.upper()
39
+
40
+ def shout(self):
41
+ return self._shout()
@@ -0,0 +1,13 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ export const LIMIT = 10;
4
+
5
+ export function load(path: string): string {
6
+ return readFileSync(path, "utf8").slice(0, LIMIT);
7
+ }
8
+
9
+ export class Loader {
10
+ read(path: string): string {
11
+ return load(path);
12
+ }
13
+ }