forgedock 1.27.0 → 1.29.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 (159) hide show
  1. package/README.md +4 -1
  2. package/bin/bootstrap.mjs +55 -0
  3. package/bin/engine/outcome-codes.mjs +39 -0
  4. package/bin/engine/telemetry-seam.mjs +37 -8
  5. package/bin/engine/telemetry-seam.test.mjs +31 -0
  6. package/bin/engine-cli.mjs +601 -42
  7. package/bin/engine.mjs +8 -4
  8. package/bin/forgedock.mjs +75 -9
  9. package/bin/gc.mjs +580 -0
  10. package/bin/lean/admission.mjs +33 -3
  11. package/bin/lean/batch.mjs +22 -4
  12. package/bin/lean/batch.test.mjs +5 -3
  13. package/bin/lean/checks.mjs +199 -0
  14. package/bin/lean/checks.test.mjs +183 -0
  15. package/bin/lean/conformance.mjs +234 -57
  16. package/bin/lean/conformance.test.mjs +168 -7
  17. package/bin/lean/context.mjs +333 -0
  18. package/bin/lean/context.test.mjs +279 -0
  19. package/bin/lean/credentials.mjs +29 -0
  20. package/bin/lean/depcache.mjs +312 -0
  21. package/bin/lean/depcache.test.mjs +298 -0
  22. package/bin/lean/drift.mjs +198 -0
  23. package/bin/lean/drift.test.mjs +223 -0
  24. package/bin/lean/effects.mjs +104 -9
  25. package/bin/lean/execution.e2e.test.mjs +81 -0
  26. package/bin/lean/execution.mjs +431 -0
  27. package/bin/lean/execution.test.mjs +583 -0
  28. package/bin/lean/heavy.mjs +289 -0
  29. package/bin/lean/heavy.test.mjs +207 -0
  30. package/bin/lean/lean.mjs +723 -154
  31. package/bin/lean/lean.test.mjs +1603 -35
  32. package/bin/lean/ledger.mjs +27 -2
  33. package/bin/lean/ledger.test.mjs +36 -1
  34. package/bin/lean/limits.mjs +58 -0
  35. package/bin/lean/limits.test.mjs +150 -0
  36. package/bin/lean/orchestrate.mjs +389 -19
  37. package/bin/lean/prefilter.mjs +2 -2
  38. package/bin/lean/prefilter.test.mjs +3 -0
  39. package/bin/lean/prompts.mjs +106 -17
  40. package/bin/lean/recovery.test.mjs +230 -0
  41. package/bin/lean/refactor.mjs +276 -0
  42. package/bin/lean/refactor.test.mjs +359 -0
  43. package/bin/lean/render.mjs +49 -10
  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/testmap.mjs +84 -0
  53. package/bin/lean/testmap.test.mjs +58 -0
  54. package/bin/lean/toolchain.mjs +228 -0
  55. package/bin/lean/toolchain.test.mjs +154 -0
  56. package/bin/lean/verify.mjs +66 -10
  57. package/bin/lean/verify.test.mjs +104 -3
  58. package/bin/lean/worktree.mjs +69 -0
  59. package/bin/lean/worktree.test.mjs +141 -0
  60. package/bin/runner.mjs +53 -10
  61. package/bin/stats/github-source.mjs +1 -1
  62. package/bin/stats/index.mjs +24 -2
  63. package/bin/stats/local-source.mjs +13 -0
  64. package/bin/stats/query.mjs +99 -0
  65. package/bin/stats/stats.test.mjs +94 -1
  66. package/commands/audit.md +2 -0
  67. package/commands/autopilot.md +2 -0
  68. package/commands/bootstrap.md +35 -0
  69. package/commands/cleanup.md +7 -22
  70. package/commands/forge-stats.md +2 -1
  71. package/commands/forgedock-init.md +7 -0
  72. package/commands/issue.md +89 -830
  73. package/commands/milestone.md +55 -6
  74. package/commands/orchestrate.md +62 -8
  75. package/commands/upgrade-deps.md +3 -3
  76. package/commands/work-on/build/validate.md +4 -0
  77. package/commands/work-on/close.md +2 -0
  78. package/commands/work-on/decompose.md +24 -6
  79. package/docs/CI.md +3 -3
  80. package/docs/CONFIG.md +192 -2
  81. package/docs/OUTCOME-CODES.md +22 -0
  82. package/docs/RELEASE-NOTES.md +9 -0
  83. package/docs/TELEMETRY.md +1 -1
  84. package/docs/site/command-reference.md +22 -0
  85. package/docs/site/getting-started.md +2 -0
  86. package/docs/site/start-a-new-project.md +327 -0
  87. package/package.json +1 -1
  88. package/scripts/affected-files-check.sh +91 -0
  89. package/scripts/affected-files-check.test.sh +80 -0
  90. package/scripts/bootstrap-create.mjs +393 -0
  91. package/scripts/bootstrap-validate.mjs +36 -2
  92. package/scripts/calibration.mjs +116 -0
  93. package/scripts/check-devdocs-freshness.sh +9 -4
  94. package/scripts/check-devdocs-freshness.test.sh +11 -1
  95. package/scripts/check-spec-markers.sh +4 -0
  96. package/scripts/check-spec-size.sh +29 -11
  97. package/scripts/check-spec-size.test.sh +27 -4
  98. package/scripts/ci-local/lib.mjs +8 -1
  99. package/scripts/ci-local/parallelism.test.mjs +27 -0
  100. package/scripts/ci-local/run.mjs +3 -3
  101. package/scripts/ci-local/scoping.mjs +82 -0
  102. package/scripts/ci-local.test.sh +1 -1
  103. package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
  104. package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
  105. package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
  106. package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
  107. package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
  108. package/scripts/fixtures/symbol-index/py/sample.py +41 -0
  109. package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
  110. package/scripts/fixtures/symbol-move/gen_big.py +201 -0
  111. package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
  112. package/scripts/fixtures/test-map/go/.github/workflows/ci.yml +23 -0
  113. package/scripts/fixtures/test-map/go/Makefile +5 -0
  114. package/scripts/fixtures/test-map/go/README.md +1 -0
  115. package/scripts/fixtures/test-map/go/go.mod +3 -0
  116. package/scripts/fixtures/test-map/go/internal/util/util.go +3 -0
  117. package/scripts/fixtures/test-map/go/main.go +3 -0
  118. package/scripts/fixtures/test-map/node/package.json +10 -0
  119. package/scripts/fixtures/test-map/node/packages/ui/package.json +4 -0
  120. package/scripts/fixtures/test-map/node/packages/ui/src/button.tsx +1 -0
  121. package/scripts/fixtures/test-map/node/pnpm-lock.yaml +1 -0
  122. package/scripts/fixtures/test-map/node/src/a.ts +1 -0
  123. package/scripts/fixtures/test-map/node/vitest.config.ts +1 -0
  124. package/scripts/fixtures/test-map/python/pkg/__init__.py +0 -0
  125. package/scripts/fixtures/test-map/python/pkg/mod.py +2 -0
  126. package/scripts/fixtures/test-map/python/pyproject.toml +9 -0
  127. package/scripts/fixtures/test-map/python/tests/test_mod.py +5 -0
  128. package/scripts/fixtures/test-map/python/uv.lock +1 -0
  129. package/scripts/forge-root.test.sh +1 -1
  130. package/scripts/hotspots.sh +100 -0
  131. package/scripts/hotspots.test.sh +101 -0
  132. package/scripts/issue-create.sh +197 -0
  133. package/scripts/issue-create.test.sh +166 -0
  134. package/scripts/maintenance-scan.sh +185 -0
  135. package/scripts/maintenance-scan.test.sh +89 -0
  136. package/scripts/milestone-drift.sh +206 -0
  137. package/scripts/milestone-drift.test.sh +120 -0
  138. package/scripts/refactor-verify.sh +368 -0
  139. package/scripts/refactor-verify.test.sh +202 -0
  140. package/scripts/repo-facts.sh +146 -0
  141. package/scripts/repo-facts.test.sh +106 -0
  142. package/scripts/structure-delta.sh +10 -5
  143. package/scripts/structure-delta.test.sh +3 -2
  144. package/scripts/symbol-index.mjs +253 -0
  145. package/scripts/symbol-index.py +354 -0
  146. package/scripts/symbol-index.test.sh +119 -0
  147. package/scripts/symbol-move-ts.test.sh +161 -0
  148. package/scripts/symbol-move.mjs +379 -0
  149. package/scripts/symbol-move.py +383 -0
  150. package/scripts/symbol-move.test.sh +168 -0
  151. package/scripts/test-map.sh +211 -0
  152. package/scripts/test-map.test.sh +165 -0
  153. package/scripts/wait-ci-green.sh +10 -4
  154. package/scripts/wait-ci-green.test.sh +9 -0
  155. package/templates/bootstrap/interview.md +34 -0
  156. package/templates/bootstrap/issue-body.md +42 -0
  157. package/templates/bootstrap/needs-human-issue.md +45 -0
  158. package/templates/bootstrap/stack-profiles.md +34 -0
  159. package/templates/bootstrap/technical-spike.md +75 -0
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ // SPDX-License-Identifier: AGPL-3.0-or-later
4
+ //
5
+ // gen_big.mjs <repo dir>: writes the TypeScript refactor:split fixture used by scripts/symbol-move-ts.test.sh
6
+ // (forge#4212): src/big.ts (~700 lines: exported consts, non-exported helpers each used only by its exported
7
+ // function, interfaces, classes, async functions, kept code that uses moved names, a relative import), src/util.ts
8
+ // and a node:test suite in test/big.test.ts (one skipped test). Specifiers carry the .ts extension so Node runs
9
+ // the sources with type stripping.
10
+ import { mkdirSync, writeFileSync } from "node:fs";
11
+ import path from "node:path";
12
+
13
+ const root = process.argv[2];
14
+ if (!root) { process.stderr.write("usage: gen_big.mjs <dir>\n"); process.exit(2); }
15
+ const N = 20;
16
+ const pad = (i) => String(i).padStart(2, "0");
17
+ const out = [];
18
+ out.push("// SPDX-License-Identifier: MIT", "// big.ts: a hotspot module for the split fixture.", "",
19
+ 'import { clamp, type Unit } from "./util.ts";', "");
20
+ for (let i = 0; i < N; i++) out.push(`export const LIMIT_${pad(i)} = ${100 + i};`);
21
+ out.push("");
22
+ for (let i = 0; i < N; i++) {
23
+ out.push(`// scale step ${i}`, `function _scale_${pad(i)}(x: number): number {`, ` const factor = ${i + 1};`, " return x * factor;", "}", "");
24
+ out.push(`/** compute ${i}: scaled and clamped. */`, `export function compute_${pad(i)}(x: number): number {`, ` const scaled = _scale_${pad(i)}(x);`,
25
+ ` return clamp(scaled, 0, LIMIT_${pad(i)});`, "}", "");
26
+ }
27
+ for (let i = 0; i < N; i++) {
28
+ out.push(`export interface Item_${pad(i)} {`, " id: number;", " unit: Unit;", "}", "");
29
+ out.push(`export class Store_${pad(i)} {`, ` items: Item_${pad(i)}[] = [];`, ` add(item: Item_${pad(i)}): number {`, " this.items.push(item);",
30
+ ` return compute_${pad(i)}(item.id);`, " }", "", " size(): number {", " return this.items.length;", " }", "}", "");
31
+ }
32
+ for (let i = 0; i < N; i++) {
33
+ out.push(`export async function fetch_${pad(i)}(x: number): Promise<number> {`, " await Promise.resolve();", ` return compute_${pad(i)}(x) + 1;`, "}", "");
34
+ }
35
+ out.push("// kept code that uses moved names", "export function summary(): number {", " return compute_00(1) + LIMIT_01 + new Store_02().size();", "}", "",
36
+ "function _shared(x: number): number {", " return x + 1;", "}", "", "export function kept_total(x: number): number {", " return _shared(x) * 2;", "}", "",
37
+ "export function ping(n: number): number {", " return n <= 0 ? 0 : pong(n - 1);", "}", "", "export function pong(n: number): number {", " return n <= 0 ? 1 : ping(n - 1);", "}");
38
+
39
+ const test = [
40
+ 'import { test } from "node:test";', 'import assert from "node:assert/strict";',
41
+ 'import { compute_03, LIMIT_05, Store_04, fetch_06, summary, kept_total, ping } from "../src/big.ts";', "",
42
+ 'test("compute clamps", () => { assert.equal(compute_03(1000), 103); assert.equal(compute_03(2), 8); });',
43
+ 'test("limits", () => { assert.equal(LIMIT_05, 105); });',
44
+ 'test("store", () => { const s = new Store_04(); assert.equal(s.add({ id: 2, unit: "m" }), 10); assert.equal(s.size(), 1); });',
45
+ 'test("fetch", async () => { assert.equal(await fetch_06(1), 8); });',
46
+ 'test("kept", () => { assert.equal(summary(), 1 + 101 + 0); assert.equal(kept_total(1), 4); assert.equal(ping(3), 1); });',
47
+ 'test("later", { skip: "not yet" }, () => {});', "",
48
+ ];
49
+
50
+ mkdirSync(path.join(root, "src"), { recursive: true });
51
+ mkdirSync(path.join(root, "test"), { recursive: true });
52
+ writeFileSync(path.join(root, "src/big.ts"), out.join("\n") + "\n");
53
+ writeFileSync(path.join(root, "src/util.ts"),
54
+ 'export type Unit = "m" | "s";\n\nexport function clamp(x: number, lo: number, hi: number): number {\n return Math.min(hi, Math.max(lo, x));\n}\n');
55
+ writeFileSync(path.join(root, "test/big.test.ts"), test.join("\n"));
56
+ writeFileSync(path.join(root, "package.json"), '{ "name": "split-fixture", "private": true, "type": "module" }\n');
@@ -0,0 +1,23 @@
1
+ name: ci
2
+ on:
3
+ pull_request:
4
+ paths:
5
+ - '**.go'
6
+ - 'go.mod'
7
+ jobs:
8
+ lint:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ - run: golangci-lint run ./...
13
+ test:
14
+ runs-on: ubuntu-latest
15
+ timeout-minutes: 30
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - name: test
19
+ run: |
20
+ go mod download
21
+ go test ./...
22
+ - name: docs
23
+ run: ./scripts/check-docs
@@ -0,0 +1,5 @@
1
+ build:
2
+ go build ./...
3
+
4
+ test:
5
+ go test ./...
@@ -0,0 +1 @@
1
+ fixture
@@ -0,0 +1,3 @@
1
+ module example.com/fixture
2
+
3
+ go 1.22
@@ -0,0 +1,3 @@
1
+ package util
2
+
3
+ func Double(n int) int { return n * 2 }
@@ -0,0 +1,3 @@
1
+ package main
2
+
3
+ func main() {}
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "fixture-node",
3
+ "private": true,
4
+ "workspaces": ["packages/*"],
5
+ "scripts": {
6
+ "build": "tsc -p .",
7
+ "test": "vitest run",
8
+ "lint": "eslint ."
9
+ }
10
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "name": "@fixture/ui",
3
+ "scripts": { "test": "vitest run", "typecheck": "tsc --noEmit" }
4
+ }
@@ -0,0 +1 @@
1
+ export const Button = () => null;
@@ -0,0 +1 @@
1
+ lockfileVersion: '9.0'
@@ -0,0 +1 @@
1
+ export const a = 1;
@@ -0,0 +1 @@
1
+ export default {};
@@ -0,0 +1,2 @@
1
+ def add(a, b):
2
+ return a + b
@@ -0,0 +1,9 @@
1
+ [project]
2
+ name = "fixture-python"
3
+ version = "0.0.0"
4
+
5
+ [tool.pytest.ini_options]
6
+ testpaths = ["tests"]
7
+
8
+ [tool.ruff]
9
+ line-length = 100
@@ -0,0 +1,5 @@
1
+ from pkg.mod import add
2
+
3
+
4
+ def test_add():
5
+ assert add(1, 2) == 3
@@ -0,0 +1 @@
1
+ version = 1
@@ -341,7 +341,7 @@ hr_check_stub review-pr "$HR_CLASSIFY" DERIVE_MILESTONE_SCRIPT derive-finding-mi
341
341
  hr_check_stub review-pr "$HR_CLASSIFY" PRIORITY_SCRIPT severity-to-priority.sh
342
342
  hr_check_stub review-prepare "$HR_PREPARE" CODE_INDEX_SCRIPT code-index.sh
343
343
  hr_check_stub review-pr "$HR_CLASSIFY" DEDUP_SCRIPT issue-dedup.sh
344
- hr_check_block issue "$ROOT/commands/issue.md" DEDUP_SCRIPT issue-dedup.sh
344
+ hr_check_stub issue "$ROOT/commands/issue.md" ISSUE_CREATE_SCRIPT issue-create.sh
345
345
  stub_block "$HR_CLASSIFY" DERIVE_MILESTONE_SCRIPT > "$T/hr_dm.sh"
346
346
  stub_block "$HR_CLASSIFY" PRIORITY_SCRIPT > "$T/hr_pr.sh"
347
347
  # no bare cwd-relative helper call in either review spec
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # hotspots.sh — deterministic size and churn facts for named files (forge#4195). No LLM, no network: git + wc + jq,
6
+ # yq (optional) only to read forge.yaml. Run from the repo root.
7
+ #
8
+ # Usage: hotspots.sh [--base <ref>] [--] <path>...
9
+ # Config: forge.yaml maintenance.hotspot {enabled, file_lines, growth_lines, exclude_globs}; defaults true/2000/150/[]
10
+ # (also when forge.yaml or yq is missing).
11
+ # Output (stdout, one JSON object):
12
+ # {"enabled","file_lines","growth_lines","files":[{path,lines,commits_90d,hotspot,over,excluded,base_lines,growth}]}
13
+ # lines = `wc -l` of the working-tree file (0 when missing); commits_90d = `git rev-list --count --since=90.days HEAD`;
14
+ # hotspot = lines × commits_90d; over = lines > file_lines; excluded = linguist-generated / linguist-vendored set by
15
+ # .gitattributes, or a match of an exclude_glob (shell pattern, `*` crosses `/`). Without --base no blob is read and
16
+ # base_lines/growth are null; with --base they are the line count at git merge-base(<ref>, HEAD) (0 when absent there)
17
+ # and lines - base_lines. enabled:false prints files:[] and does no per-file work.
18
+ # Exit: 0 ok | 2 usage or bad ref. bash 3.2 portable.
19
+ set -uo pipefail
20
+
21
+ die() { echo "hotspots: $*" >&2; exit 2; }
22
+ command -v jq >/dev/null 2>&1 || die "jq is required"
23
+ BASE=""
24
+ while [ $# -gt 0 ]; do
25
+ case "$1" in
26
+ --base) [ $# -ge 2 ] && [ -n "$2" ] || die "usage: hotspots.sh [--base <ref>] [--] <path>..."; BASE="$2"; shift 2 ;;
27
+ --) shift; break ;;
28
+ -*) die "unknown option: $1" ;;
29
+ *) break ;;
30
+ esac
31
+ done
32
+
33
+ # ---- config: maintenance.hotspot as JSON, {} when unreadable ----
34
+ CFG='{}'
35
+ if [ -f forge.yaml ] && command -v yq >/dev/null 2>&1; then
36
+ c=$(yq -o=json '.maintenance.hotspot // {}' forge.yaml 2>/dev/null) || c=""
37
+ printf '%s' "$c" | jq -e 'type == "object"' >/dev/null 2>&1 && CFG="$c"
38
+ fi
39
+ CFG=$(printf '%s' "$CFG" | jq -c '
40
+ def pos($d): if type == "number" and . > 0 then floor else $d end;
41
+ { enabled: (.enabled != false), file_lines: (.file_lines | pos(2000)), growth_lines: (.growth_lines | pos(150)),
42
+ exclude_globs: ([(.exclude_globs // [])[]? | strings]) }')
43
+ ENABLED=$(printf '%s' "$CFG" | jq -r '.enabled')
44
+ FILE_LINES=$(printf '%s' "$CFG" | jq -r '.file_lines')
45
+ GROWTH_LINES=$(printf '%s' "$CFG" | jq -r '.growth_lines')
46
+ head_json() { printf '%s' "$CFG" | jq -c --argjson files "$1" '{enabled, file_lines, growth_lines, files: $files}'; }
47
+ [ "$ENABLED" = true ] || { head_json '[]'; exit 0; }
48
+
49
+ MB=""
50
+ if [ -n "$BASE" ]; then
51
+ git rev-parse --verify -q "$BASE^{commit}" >/dev/null || die "bad base ref: $BASE"
52
+ MB=$(git merge-base "$BASE" HEAD) || die "no merge base for $BASE and HEAD"
53
+ fi
54
+ HAS_HEAD=0; git rev-parse --verify -q "HEAD^{commit}" >/dev/null && HAS_HEAD=1
55
+ GLOBS=$(printf '%s' "$CFG" | jq -r '.exclude_globs[]')
56
+
57
+ T=$(mktemp -d "${TMPDIR:-/tmp}/hotspots.XXXXXX") || die "mktemp failed"
58
+ trap 'rm -rf "$T"' EXIT
59
+ : > "$T/rows"
60
+ # one history walk per file, 8 at a time: wall time is the slowest batch, not the sum
61
+ i=0
62
+ for p in "$@"; do
63
+ i=$((i+1))
64
+ [ "$HAS_HEAD" = 1 ] && [ -n "${p#./}" ] && { git rev-list --count --since=90.days HEAD -- "${p#./}" > "$T/c.$i" 2>/dev/null & }
65
+ [ $((i % 8)) -eq 0 ] && wait
66
+ done
67
+ wait
68
+ i=0
69
+ for p in "$@"; do
70
+ i=$((i+1))
71
+ p=${p#./}
72
+ [ -n "$p" ] || continue
73
+ lines=0; [ -f "$p" ] && lines=$(wc -l < "$p" | tr -d ' ')
74
+ commits=$(cat "$T/c.$i" 2>/dev/null); case "$commits" in ''|*[!0-9]*) commits=0 ;; esac
75
+ excluded=false
76
+ if git check-attr linguist-generated linguist-vendored -- "$p" 2>/dev/null | grep -qE ': (set|true)$'; then excluded=true; fi
77
+ if [ "$excluded" = false ] && [ -n "$GLOBS" ]; then
78
+ while IFS= read -r g; do
79
+ [ -n "$g" ] || continue
80
+ # shellcheck disable=SC2254
81
+ case "$p" in $g) excluded=true; break ;; esac
82
+ done <<EOF
83
+ $GLOBS
84
+ EOF
85
+ fi
86
+ base_lines=null; growth=null
87
+ if [ -n "$MB" ]; then
88
+ base_lines=0
89
+ git cat-file -e "$MB:$p" 2>/dev/null && base_lines=$(git show "$MB:$p" | wc -l | tr -d ' ')
90
+ growth=$((lines - base_lines))
91
+ fi
92
+ printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$p" "$lines" "$commits" "$excluded" "$base_lines" "$growth" >> "$T/rows"
93
+ done
94
+
95
+ FILES=$(jq -R -s -c --argjson fl "$FILE_LINES" '
96
+ [split("\n")[] | select(length > 0) | split("\t") |
97
+ { path: .[0], lines: (.[1] | tonumber), commits_90d: (.[2] | tonumber) } as $r |
98
+ $r + { hotspot: ($r.lines * $r.commits_90d), over: ($r.lines > $fl), excluded: (.[3] == "true"),
99
+ base_lines: (.[4] | fromjson), growth: (.[5] | fromjson) }]' < "$T/rows")
100
+ head_json "$FILES"
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env bash
2
+ # hotspots.test.sh — cases for scripts/hotspots.sh (forge#4195).
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)/hotspots.sh"
7
+ PASS=0; FAILN=0
8
+ TMP="$(mktemp -d "${TMPDIR:-/tmp}/hotspots-test.XXXXXX")"; trap 'rm -rf "$TMP"' EXIT
9
+ ok() { PASS=$((PASS+1)); }
10
+ bad() { FAILN=$((FAILN+1)); echo "FAIL: $1"; }
11
+ eq() { if [ "$2" = "$3" ]; then ok; else bad "$1 (got: $2 want: $3)"; fi }
12
+ q() { printf '%s' "$OUT" | jq -c "$1"; }
13
+ HAVE_YQ=1; yq -o=json '.' - <<<'a: 1' 2>/dev/null | grep -q '"a"' || HAVE_YQ=0
14
+
15
+ new_repo() {
16
+ rm -rf "$TMP/w"; mkdir -p "$TMP/w"; cd "$TMP/w" || exit 1
17
+ git init -q -b main; git config user.email t@example.com; git config user.name t; git config commit.gpgsign false
18
+ }
19
+ commit() { git add -A; git commit -qm "$1"; }
20
+ lines() { i=0; : > "$1"; while [ $i -lt "$2" ]; do echo "l$i" >> "$1"; i=$((i+1)); done; }
21
+
22
+ # ---- 1. exact lines, commits_90d, hotspot, over; old commits outside the window are not counted ----
23
+ new_repo
24
+ lines big.py 2100; GIT_COMMITTER_DATE="2000-01-01T00:00:00Z" GIT_AUTHOR_DATE="2000-01-01T00:00:00Z" commit old
25
+ echo x >> big.py; commit c1
26
+ echo y >> big.py; lines small.py 10; commit c2
27
+ OUT="$(bash "$S" big.py ./small.py missing/gone.py)"; rc=$?
28
+ eq "rc" "$rc" 0
29
+ eq "defaults" "$(q '[.enabled,.file_lines,.growth_lines]')" '[true,2000,150]'
30
+ eq "big facts" "$(q '.files[0]')" '{"path":"big.py","lines":2102,"commits_90d":2,"hotspot":4204,"over":true,"excluded":false,"base_lines":null,"growth":null}'
31
+ eq "small facts (./ stripped)" "$(q '.files[1] | [.path,.lines,.commits_90d,.over]')" '["small.py",10,1,false]'
32
+ eq "missing file" "$(q '.files[2] | [.path,.lines,.commits_90d,.over]')" '["missing/gone.py",0,0,false]'
33
+ OUT="$(bash "$S")"; eq "no paths" "$(q '.files')" '[]'
34
+
35
+ # ---- 2. --base growth: merge-base side, new file counts from 0 ----
36
+ git checkout -q -b feat
37
+ lines extra 200; cat extra >> big.py; lines fresh.py 30; commit grow
38
+ OUT="$(bash "$S" --base main -- big.py fresh.py)"
39
+ eq "growth" "$(q '.files[0] | [.base_lines,.growth,.lines]')" '[2102,200,2302]'
40
+ eq "new file growth" "$(q '.files[1] | [.base_lines,.growth]')" '[0,30]'
41
+ bash "$S" --base no-such-ref big.py >/dev/null 2>&1; eq "bad ref exit" "$?" 2
42
+ bash "$S" --base >/dev/null 2>&1; eq "usage exit" "$?" 2
43
+ bash "$S" --bogus x >/dev/null 2>&1; eq "unknown option exit" "$?" 2
44
+
45
+ # ---- 3. exclusions: linguist-generated, linguist-vendored, forge.yaml globs ----
46
+ mkdir -p gen vendor/lib dist
47
+ lines gen/api.py 2500; lines vendor/lib/x.js 2500; lines dist/bundle.js 2500
48
+ printf 'gen/** linguist-generated\nvendor/** linguist-vendored=true\n' > .gitattributes
49
+ commit gen
50
+ OUT="$(bash "$S" gen/api.py vendor/lib/x.js dist/bundle.js big.py)"
51
+ eq "attr exclusions" "$(q '[.files[] | .excluded]')" '[true,true,false,false]'
52
+ if [ "$HAVE_YQ" = 1 ]; then
53
+ printf 'maintenance:\n hotspot:\n file_lines: 100\n growth_lines: 5\n exclude_globs: ["dist/*", "*.lock"]\n' > forge.yaml
54
+ OUT="$(bash "$S" dist/bundle.js small.py big.py)"
55
+ eq "config thresholds" "$(q '[.file_lines,.growth_lines]')" '[100,5]'
56
+ eq "glob exclusion" "$(q '[.files[] | .excluded]')" '[true,false,false]'
57
+ eq "over uses configured file_lines" "$(q '[.files[] | .over]')" '[true,false,true]'
58
+ printf 'maintenance:\n hotspot:\n enabled: false\n' > forge.yaml
59
+ OUT="$(bash "$S" big.py)"
60
+ eq "opt-out" "$(q '[.enabled,.files]')" '[false,[]]'
61
+ printf 'maintenance:\n hotspot:\n file_lines: nope\n' > forge.yaml
62
+ OUT="$(bash "$S" big.py)"; eq "bad value falls back" "$(q '.file_lines')" 2000
63
+ rm -f forge.yaml
64
+ else
65
+ echo "SKIP: forge.yaml cases need mikefarah yq v4"
66
+ fi
67
+ # yq missing: defaults, still exit 0
68
+ mkdir -p "$TMP/noyq"; printf '#!/bin/sh\nexit 1\n' > "$TMP/noyq/yq"; chmod +x "$TMP/noyq/yq"
69
+ printf 'maintenance:\n hotspot:\n file_lines: 100\n' > forge.yaml
70
+ OUT="$(PATH="$TMP/noyq:$PATH" bash "$S" big.py)"; eq "yq unusable: defaults" "$(q '.file_lines')" 2000
71
+ rm -f forge.yaml
72
+
73
+ # ---- 4. 30k-commit repo: exact counts under 1 s, no blob reads, no network ----
74
+ rm -rf "$TMP/big"; git init -q -b main "$TMP/big"; cd "$TMP/big" || exit 1
75
+ now=$(date +%s)
76
+ awk -v now="$now" 'BEGIN { for (i = 1; i <= 30000; i++) {
77
+ ts = (i > 27000) ? now - (30000 - i) * 60 : now - 200 * 86400 - (27000 - i) * 600
78
+ print "commit refs/heads/main"; print "mark :" i; print "committer t <t@e> " ts " +0000"; print "data 1"; print "x"
79
+ if (i > 1) print "from :" (i - 1)
80
+ f = (i % 10 == 0) ? "hot.py" : "other" (i % 50) ".txt"; c = "line " i
81
+ print "M 644 inline " f; print "data " length(c) + 1; print c; print "" } }' | git fast-import --quiet
82
+ git checkout -q main
83
+ mkdir -p "$TMP/bin"
84
+ for t in gh curl wget npm npx pnpm yarn nc ssh; do printf '#!/bin/sh\necho "%s" >> "%s/net.log"\nexit 1\n' "$t" "$TMP" > "$TMP/bin/$t"; chmod +x "$TMP/bin/$t"; done
85
+ best=""
86
+ for _ in 1 2 3; do
87
+ start=$(date +%s%N 2>/dev/null)
88
+ OUT="$(PATH="$TMP/bin:$PATH" GIT_TRACE2_EVENT="$TMP/trace.json" bash "$S" hot.py other1.txt other2.txt other3.txt)"
89
+ end=$(date +%s%N 2>/dev/null)
90
+ case "$start$end" in *N*|"") best=0; break ;; esac
91
+ ms=$(( (end - start) / 1000000 )); if [ -z "$best" ] || [ "$ms" -lt "$best" ]; then best=$ms; fi
92
+ done
93
+ eq "30k exact commits" "$(q '[.files[] | .commits_90d]')" '[300,60,60,60]'
94
+ eq "30k exact lines" "$(q '[.files[] | .lines]')" '[1,1,1,1]'
95
+ if [ "$best" -lt 1000 ]; then ok; else bad "30k-commit run took ${best} ms (limit 1000)"; fi
96
+ grep -q '"rev-list"' "$TMP/trace.json" && ok || bad "git trace not captured"
97
+ if grep -qE '"(cat-file|show)"' "$TMP/trace.json"; then bad "blob read without --base"; else ok; fi
98
+ if [ -e "$TMP/net.log" ]; then bad "network tool invoked: $(tr '\n' ' ' < "$TMP/net.log")"; else ok; fi
99
+
100
+ echo "hotspots.test.sh: $PASS passed, $FAILN failed"
101
+ [ "$FAILN" -eq 0 ]
@@ -0,0 +1,197 @@
1
+ #!/usr/bin/env bash
2
+ # issue-create.sh — deterministic /issue create path (forge#4164): parse, integrity check, dedup, section repair,
3
+ # REST create with token read-back, milestone, project board. commands/issue.md calls it once.
4
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #
7
+ # Usage: issue-create.sh --title T (--body B | --body-file F) [--label L]... [--milestone M] [--exclude N,N]
8
+ # [--dry-run] [--repo OWNER/NAME] [--config forge.yaml]
9
+ # issue-create.sh --args "<raw \$ARGUMENTS string>" [--repo ...] [--config ...]
10
+ # --args is tokenized quote-aware by xargs (never eval: $(...), backticks, ; and | in caller text are never run, #2094).
11
+ # Repo: --repo, else yq .project.owner/.project.repo of --config (default ${FORGE_CONFIG:-forge.yaml}).
12
+ #
13
+ # Output: exactly one stdout line per run
14
+ # ISSUE_CREATE_RESULT:CREATED number=N url=U exit 0
15
+ # ISSUE_CREATE_RESULT:DRY_RUN exit 0 (draft printed, nothing created)
16
+ # ISSUE_CREATE_RESULT:DEDUP number=N exit 1 (an open near-duplicate exists; nothing created)
17
+ # ISSUE_CREATE_RESULT:USAGE exit 2 (bad flags, body or integrity marker, dedup usage error)
18
+ # ISSUE_CREATE_RESULT:ERROR exit 3 (GitHub rejected the create or a read-back failed)
19
+ # Diagnostics go to stderr. Bash 3.2 portable: no mapfile, no assoc arrays, no ${arr[@]} under set -u.
20
+ set -o pipefail
21
+
22
+ usage() { echo "ERROR: $*" >&2; echo "ISSUE_CREATE_RESULT:USAGE"; exit 2; }
23
+ fail() { echo "ERROR: $*" >&2; echo "ISSUE_CREATE_RESULT:ERROR"; exit 3; }
24
+
25
+ SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
26
+ TITLE=""; BODY=""; BODY_FILE=""; HAVE_BODY=false; MILESTONE=""; EXCLUDE=""; DRY_RUN=false
27
+ REPO=""; CONFIG="${FORGE_CONFIG:-forge.yaml}"; RAW_ARGS=""; HAVE_RAW=false
28
+ LABELS=()
29
+
30
+ # --- tokenize --args (quote-aware, never executed) ---------------------------
31
+ TOKENS=()
32
+ take_tokens() { # tokenize $1 into TOKENS via a temp file so the real xargs exit status is seen
33
+ local tmp tok
34
+ case "$1" in *[![:space:]]*) ;; *) return 0 ;; esac
35
+ tmp=$(mktemp "${TMPDIR:-/tmp}/issue-create-args.XXXXXX") || usage "mktemp failed"
36
+ if ! printf '%s' "$1" | xargs -n1 printf '%s\0' > "$tmp"; then
37
+ rm -f "$tmp"
38
+ usage "failed to tokenize the arguments (unbalanced quote, or a token over xargs's limit; use --body-file for large content)"
39
+ fi
40
+ while IFS= read -r -d '' tok; do TOKENS+=("$tok"); done < "$tmp"
41
+ rm -f "$tmp"
42
+ }
43
+
44
+ # Outer argv: --args/--repo/--config, or the flags themselves.
45
+ while [ $# -gt 0 ]; do
46
+ case "$1" in
47
+ --args) [ $# -ge 2 ] || usage "--args requires a value"; RAW_ARGS="$2"; HAVE_RAW=true; shift 2 ;;
48
+ --repo) [ $# -ge 2 ] || usage "--repo requires a value"; REPO="$2"; shift 2 ;;
49
+ --config) [ $# -ge 2 ] || usage "--config requires a value"; CONFIG="$2"; shift 2 ;;
50
+ *) TOKENS+=("$1"); shift ;;
51
+ esac
52
+ done
53
+ [ "$HAVE_RAW" = true ] && take_tokens "$RAW_ARGS"
54
+
55
+ # Flag mode only when a token is exactly a known flag: free text that mentions "--verbose" is not a flag typo.
56
+ FLAG_MODE=false
57
+ for t in ${TOKENS[@]+"${TOKENS[@]}"}; do
58
+ case "$t" in --dry-run|--greenfield|--spike|--title|--body|--body-file|--label|--milestone|--exclude) FLAG_MODE=true; break ;; esac
59
+ done
60
+ [ "$FLAG_MODE" = true ] || usage "no recognised flag: free-text /issue requests are drafted interactively (commands/issue.md), not by this script"
61
+
62
+ i=0; n=${#TOKENS[@]}
63
+ while [ "$i" -lt "$n" ]; do
64
+ arg="${TOKENS[$i]}"
65
+ case "$arg" in
66
+ --dry-run) DRY_RUN=true ;;
67
+ --greenfield) ;; # interactive draft validation only (forge#4172); accepted, no effect on a programmatic create
68
+ --title|--body|--body-file|--label|--milestone|--exclude|--spike)
69
+ [ $((i + 1)) -lt "$n" ] || usage "$arg requires a value"
70
+ i=$((i + 1)); v="${TOKENS[$i]}"
71
+ case "$arg" in
72
+ --title) TITLE="$v" ;;
73
+ --body) BODY="$v"; HAVE_BODY=true ;;
74
+ --body-file) BODY_FILE="$v" ;;
75
+ --label) LABELS+=("$v") ;;
76
+ --milestone) MILESTONE="$v" ;;
77
+ --exclude) EXCLUDE="$v" ;;
78
+ --spike) ;; # see --greenfield
79
+ esac ;;
80
+ --*) usage "unrecognized flag: $arg" ;;
81
+ *) ;; # stray words are ignored, as in the spec's flag loop
82
+ esac
83
+ i=$((i + 1))
84
+ done
85
+
86
+ # --- body and integrity marker ------------------------------------------------
87
+ [ -n "$TITLE" ] || usage "programmatic mode requires --title"
88
+ if [ "$HAVE_BODY" = true ] && [ -n "$BODY_FILE" ]; then
89
+ usage "programmatic mode requires exactly one of --body or --body-file (both given)"
90
+ elif [ "$HAVE_BODY" != true ] && [ -z "$BODY_FILE" ]; then
91
+ usage "programmatic mode requires exactly one of --body or --body-file (neither given)"
92
+ fi
93
+ MARKER=""
94
+ if [ -n "$BODY_FILE" ]; then
95
+ [ -f "$BODY_FILE" ] && [ -r "$BODY_FILE" ] || usage "--body-file '$BODY_FILE' is missing or unreadable"
96
+ BODY=$(cat "$BODY_FILE") || usage "--body-file '$BODY_FILE' is missing or unreadable"
97
+ MARKERS=$(printf '%s' "$BODY" | grep -oE '<!-- FORGE:BODY-INTEGRITY:[^>]+ -->' || true)
98
+ count=0; [ -n "$MARKERS" ] && count=$(printf '%s\n' "$MARKERS" | wc -l | tr -d ' ')
99
+ [ "$count" -eq 1 ] || usage "--body-file must contain exactly one FORGE:BODY-INTEGRITY marker (found $count)"
100
+ MARKER="$MARKERS"
101
+ fi
102
+
103
+ # --- repository ----------------------------------------------------------------
104
+ if [ -z "$REPO" ] && [ -f "$CONFIG" ] && command -v yq >/dev/null 2>&1; then
105
+ REPO=$(yq '.project.owner + "/" + .project.repo' "$CONFIG" 2>/dev/null | tr -d '"')
106
+ fi
107
+ case "$REPO" in ?*/?*) ;; *) usage "no target repository: pass --repo OWNER/NAME or a forge.yaml with project.owner/project.repo" ;; esac
108
+
109
+ # --- dedup (helper resolved from this script's own install, never the cwd) ---
110
+ DEDUP_SCRIPT="$SELF_DIR/issue-dedup.sh"
111
+ [ -f "$DEDUP_SCRIPT" ] || fail "issue-dedup.sh not found next to issue-create.sh ($SELF_DIR)"
112
+ DEDUP_ARGS=("$TITLE" -R "$REPO")
113
+ [ -n "$EXCLUDE" ] && DEDUP_ARGS+=(--exclude "$EXCLUDE")
114
+ DEDUP_RESULT=$(bash "$DEDUP_SCRIPT" "${DEDUP_ARGS[@]}" 2>&1); DEDUP_EXIT=$?
115
+ if [ "$DEDUP_EXIT" -eq 1 ]; then
116
+ echo "Near-duplicate detected: $DEDUP_RESULT" >&2
117
+ DEDUP_NUMBER=$(printf '%s\n' "$DEDUP_RESULT" | grep -oE '#[0-9]+' | head -1 | tr -d '#')
118
+ [ -n "$DEDUP_NUMBER" ] || usage "dedup STOP returned no issue number"
119
+ echo "ISSUE_CREATE_RESULT:DEDUP number=${DEDUP_NUMBER}"
120
+ exit 1
121
+ elif [ "$DEDUP_EXIT" -ne 0 ]; then
122
+ usage "dedup check usage error: $DEDUP_RESULT"
123
+ fi
124
+
125
+ # --- mandatory sections (repair, never block) ----------------------------------
126
+ section_repair() { # body -> text to append for missing sections
127
+ local b="$1" add=""
128
+ printf '%s\n' "$b" | grep -q "^## Problem" || add="$add
129
+ ## Problem
130
+
131
+ Root cause unknown — investigation needed."
132
+ printf '%s\n' "$b" | grep -q "^## Affected Files" || add="$add
133
+ ## Affected Files
134
+
135
+ Files to be identified during investigation."
136
+ printf '%s\n' "$b" | grep -q "^## Acceptance Criteria" || add="$add
137
+ ## Acceptance Criteria
138
+
139
+ - [ ] Fix confirmed during investigation."
140
+ printf '%s' "$add"
141
+ }
142
+ APPEND=$(section_repair "$BODY")
143
+ if [ -n "$APPEND" ]; then
144
+ BODY="${BODY}${APPEND}"
145
+ echo "WARNING: body was missing mandatory sections; placeholders added before creation" >&2
146
+ fi
147
+
148
+ if [ "$DRY_RUN" = true ]; then
149
+ LABEL_TEXT=""; for l in ${LABELS[@]+"${LABELS[@]}"}; do LABEL_TEXT="${LABEL_TEXT:+$LABEL_TEXT, }$l"; done
150
+ printf '## Issue Draft (--dry-run — not created)\n\n**Repo**: %s\n**Title**: %s\n**Labels**: %s\n**Milestone**: %s\n\n---\n\n%s\n\n---\n\nRe-run without --dry-run to create this issue.\n' \
151
+ "$REPO" "$TITLE" "${LABEL_TEXT:-none}" "${MILESTONE:-none}" "$BODY" >&2
152
+ echo "ISSUE_CREATE_RESULT:DRY_RUN"
153
+ exit 0
154
+ fi
155
+
156
+ # --- REST create + create-token read-back (never gh issue create: it can swallow a 403) ---
157
+ CREATE_TOKEN="forge-create-$(date -u +%Y%m%dT%H%M%SZ)-$$-$RANDOM"
158
+ CREATE_BODY="${BODY}
159
+
160
+ <!-- issue-create-token:${CREATE_TOKEN} -->"
161
+ API_LABELS=()
162
+ for l in ${LABELS[@]+"${LABELS[@]}"}; do API_LABELS+=(-f "labels[]=$l"); done
163
+ CREATE_RESPONSE=$(gh api "repos/${REPO}/issues" --method POST -f title="$TITLE" -f body="$CREATE_BODY" ${API_LABELS[@]+"${API_LABELS[@]}"}) \
164
+ || fail "GitHub rejected issue creation; no issue was created."
165
+ NEW_NUMBER=$(printf '%s' "$CREATE_RESPONSE" | jq -r '.number // empty' 2>/dev/null)
166
+ case "$NEW_NUMBER" in ''|*[!0-9]*) fail "issue creation returned no issue number." ;; esac
167
+ ISSUE_URL=$(printf '%s' "$CREATE_RESPONSE" | jq -r '.html_url // empty' 2>/dev/null)
168
+ CREATED_BODY=$(gh issue view "$NEW_NUMBER" -R "$REPO" --json body --jq '.body') || fail "could not read back issue #${NEW_NUMBER}."
169
+ printf '%s' "$CREATED_BODY" | grep -qF "issue-create-token:${CREATE_TOKEN}" \
170
+ || fail "issue #${NEW_NUMBER} did not contain this create token; refusing to report success."
171
+ if [ -n "$MARKER" ] && ! printf '%s\n' "$CREATED_BODY" | grep -Fqx "$MARKER"; then
172
+ fail "created issue #${NEW_NUMBER} body is missing expected integrity marker $MARKER"
173
+ fi
174
+
175
+ if [ -n "$MILESTONE" ]; then
176
+ gh issue edit "$NEW_NUMBER" -R "$REPO" --milestone "$MILESTONE" >&2 || fail "could not set milestone '$MILESTONE' on #${NEW_NUMBER}."
177
+ fi
178
+
179
+ # Post-create safety net: GitHub or a transform may have altered the body; extend, never replace.
180
+ APPEND=$(section_repair "$CREATED_BODY")
181
+ if [ -n "$APPEND" ]; then
182
+ gh issue edit "$NEW_NUMBER" -R "$REPO" --body "${CREATED_BODY}${APPEND}" >&2 || echo "WARNING: could not append missing sections to #${NEW_NUMBER}" >&2
183
+ fi
184
+
185
+ # Project board (best effort), from forge.yaml project_board.
186
+ if [ -f "$CONFIG" ] && command -v yq >/dev/null 2>&1; then
187
+ PROJECT_NUMBER=$(yq '.project_board.project_number // ""' "$CONFIG" 2>/dev/null | tr -d '"')
188
+ PROJECT_OWNER=$(yq '.project_board.owner // .project.owner' "$CONFIG" 2>/dev/null | tr -d '"')
189
+ if [ -n "$PROJECT_NUMBER" ] && [ "$PROJECT_NUMBER" != "null" ] && [ -n "$ISSUE_URL" ]; then
190
+ gh project item-add "$PROJECT_NUMBER" --owner "$PROJECT_OWNER" --url "$ISSUE_URL" >/dev/null 2>&1 || true
191
+ fi
192
+ fi
193
+
194
+ [ -n "$ISSUE_URL" ] || ISSUE_URL="https://github.com/${REPO}/issues/${NEW_NUMBER}"
195
+ echo "Created: ${ISSUE_URL}" >&2
196
+ echo "ISSUE_CREATE_RESULT:CREATED number=${NEW_NUMBER} url=${ISSUE_URL}"
197
+ exit 0