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
@@ -171,7 +171,20 @@ function detectDefaultBranch(cwd) {
171
171
  // symbolic-ref not set — try next strategy
172
172
  }
173
173
 
174
- // Strategy 2: current local branch
174
+ // Strategy 2: unborn branch (forge#4173) — a freshly `git init`-ed repo
175
+ // with no commits has no resolvable HEAD commit, so `rev-parse` below
176
+ // fails, but `symbolic-ref --short HEAD` still names the branch the first
177
+ // commit will land on. Prefer it over guessing "main".
178
+ const unborn = detectUnbornBranch(cwd);
179
+ if (unborn) {
180
+ return medium(
181
+ unborn,
182
+ "git symbolic-ref --short HEAD",
183
+ `Repository has no commits yet; HEAD points at unborn branch ${unborn}`,
184
+ );
185
+ }
186
+
187
+ // Strategy 3: current local branch
175
188
  try {
176
189
  const cur = execFileSync(
177
190
  "git",
@@ -194,10 +207,53 @@ function detectDefaultBranch(cwd) {
194
207
  // git unavailable or not a git repo
195
208
  }
196
209
 
197
- // Strategy 3: fall back
210
+ // Strategy 4: fall back
198
211
  return low("main", "default placeholder", "Could not determine branch from git; defaulting to 'main'");
199
212
  }
200
213
 
214
+ /**
215
+ * Run a read-only git command, returning trimmed stdout or null on failure.
216
+ * @param {string} cwd
217
+ * @param {string[]} args
218
+ * @returns {string|null}
219
+ */
220
+ function gitRead(cwd, args) {
221
+ try {
222
+ return execFileSync("git", args, {
223
+ cwd,
224
+ encoding: "utf-8",
225
+ stdio: ["pipe", "pipe", "pipe"],
226
+ timeout: 5000,
227
+ }).trim();
228
+ } catch {
229
+ return null;
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Return the unborn branch name when `cwd` is a git repo with no commits,
235
+ * else "" (forge#4173). Never throws.
236
+ * @param {string} cwd
237
+ * @returns {string}
238
+ */
239
+ function detectUnbornBranch(cwd) {
240
+ if (gitRead(cwd, ["rev-parse", "--verify", "--quiet", "HEAD"])) return "";
241
+ return gitRead(cwd, ["symbolic-ref", "--short", "HEAD"]) || "";
242
+ }
243
+
244
+ /**
245
+ * Detect repository state signals for the repo-setup step (forge#4173):
246
+ * whether `cwd` is inside a git work tree and whether HEAD has any commit.
247
+ * @param {string} cwd
248
+ * @returns {{ isGitRepo: boolean, hasCommits: boolean, unbornBranch: string }}
249
+ */
250
+ function detectRepoState(cwd) {
251
+ const isGitRepo = gitRead(cwd, ["rev-parse", "--is-inside-work-tree"]) === "true";
252
+ if (!isGitRepo) return { isGitRepo: false, hasCommits: false, unbornBranch: "" };
253
+ const hasCommits = Boolean(gitRead(cwd, ["rev-parse", "--verify", "--quiet", "HEAD"]));
254
+ return { isGitRepo, hasCommits, unbornBranch: hasCommits ? "" : detectUnbornBranch(cwd) };
255
+ }
256
+
201
257
  /**
202
258
  * Detect the staging branch.
203
259
  *
@@ -360,6 +416,10 @@ function deriveProjectName(repoSlug) {
360
416
  * },
361
417
  * meta: {
362
418
  * remoteDetected: boolean,
419
+ * isGitRepo: boolean, // cwd resolved to a git work tree (forge#4173)
420
+ * hasCommits: boolean, // HEAD resolves to a commit
421
+ * unbornBranch: string, // branch name when the repo has no commits, else ""
422
+ * stagingMissing: boolean, // no origin/staging — the setup step may create it
363
423
  * },
364
424
  * }} ConfigDraft
365
425
  */
@@ -401,6 +461,7 @@ export async function detectConfig(cwd = process.cwd()) {
401
461
  // Branch detection
402
462
  const defaultBranchField = detectDefaultBranch(gitRoot);
403
463
  const stagingBranchField = detectStagingBranch(gitRoot, defaultBranchField.value);
464
+ const repoState = detectRepoState(gitRoot);
404
465
 
405
466
  return {
406
467
  project: { owner, repo, name },
@@ -409,7 +470,11 @@ export async function detectConfig(cwd = process.cwd()) {
409
470
  default: defaultBranchField,
410
471
  staging: stagingBranchField,
411
472
  },
412
- meta: { remoteDetected },
473
+ meta: {
474
+ remoteDetected,
475
+ ...repoState,
476
+ stagingMissing: stagingBranchField.source !== "git branch -r",
477
+ },
413
478
  };
414
479
  }
415
480
 
@@ -119,6 +119,9 @@ export function resolveEnrichBackend({
119
119
  */
120
120
  export async function enrich(draft, opts = {}) {
121
121
  const { cwd = process.cwd(), env = process.env, isCliAvailableFn, bin, spawnFn, timeoutMs } = opts;
122
+ // An empty repository with no remote (forge#4173) has nothing for either
123
+ // backend to read — skip the (slow, possibly billed) call entirely.
124
+ if (draft?.meta?.remoteDetected === false && draft?.meta?.hasCommits === false) return draft;
122
125
  const backend =
123
126
  opts.backend ?? resolveEnrichBackend({ cwd, env, isCliAvailableFn });
124
127
 
@@ -0,0 +1,213 @@
1
+ /**
2
+ * init-repo.mjs — side-effecting repository setup helpers for
3
+ * `forgedock init` (issue #4173).
4
+ *
5
+ * bin/init-detect.mjs stays pure (reads only). Everything that creates a
6
+ * GitHub repository, makes the first commit, or pushes branches lives here,
7
+ * behind an injectable `exec(cmd, args)` so tests can drive every helper
8
+ * with a stub.
9
+ *
10
+ * SECURITY — every call is an argv array through execFileSync, never a
11
+ * shell. Owner and repo names come from the user and are additionally
12
+ * validated against GitHub's name charset before they reach `gh`.
13
+ *
14
+ * GitHub App coverage: the ForgeDock App can only act on repositories of
15
+ * accounts it is installed on (field test: App on the org, repo created
16
+ * under the personal account → the bot got a 404). listAppInstallations()
17
+ * reports each owner as "installed", "not-installed", or "unknown" — a
18
+ * failed lookup is never reported as covered.
19
+ */
20
+
21
+ import { execFileSync } from "child_process";
22
+
23
+ export const APP_SLUG = "rapiercraft-forgedock";
24
+ export const APP_INSTALL_URL = `https://github.com/apps/${APP_SLUG}/installations/new`;
25
+
26
+ /** GitHub account / repository name charset. */
27
+ const NAME_RE = /^[A-Za-z0-9._-]+$/;
28
+
29
+ /**
30
+ * Default exec: run `cmd args` without a shell, return trimmed stdout.
31
+ * Throws on non-zero exit.
32
+ * @param {string} cmd
33
+ * @param {string[]} args
34
+ * @returns {string}
35
+ */
36
+ export function defaultExec(cmd, args) {
37
+ return execFileSync(cmd, args, {
38
+ encoding: "utf-8",
39
+ stdio: ["pipe", "pipe", "pipe"],
40
+ timeout: 120000,
41
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
42
+ }).trim();
43
+ }
44
+
45
+ function tryExec(exec, cmd, args) {
46
+ try {
47
+ return { ok: true, out: String(exec(cmd, args) ?? "").trim() };
48
+ } catch (err) {
49
+ return { ok: false, out: "", error: err };
50
+ }
51
+ }
52
+
53
+ function lines(out) {
54
+ return out.split("\n").map((l) => l.trim()).filter(Boolean);
55
+ }
56
+
57
+ /**
58
+ * List the accounts the authenticated `gh` user can create repositories
59
+ * under: their own login first, then their organizations.
60
+ * @param {Function} exec
61
+ * @returns {{ login: string, type: 'User'|'Organization' }[]}
62
+ */
63
+ export function listOwners(exec = defaultExec) {
64
+ const owners = [];
65
+ const me = tryExec(exec, "gh", ["api", "user", "--jq", ".login"]);
66
+ if (me.ok && me.out) owners.push({ login: me.out, type: "User" });
67
+ const orgs = tryExec(exec, "gh", ["api", "user/orgs", "--paginate", "--jq", ".[].login"]);
68
+ if (orgs.ok) {
69
+ for (const login of lines(orgs.out)) owners.push({ login, type: "Organization" });
70
+ }
71
+ return owners;
72
+ }
73
+
74
+ /**
75
+ * Determine, per owner, whether the ForgeDock GitHub App is installed.
76
+ *
77
+ * Strategy:
78
+ * 1. `gh api /user/installations` — installations visible to the user.
79
+ * Authoritative for the user's own account when it succeeds.
80
+ * 2. Organizations not found in (1): `gh api /orgs/{org}/installations`
81
+ * (needs org admin). Success → installed / not-installed.
82
+ * 3. Anything else → "unknown". Never "installed" without evidence.
83
+ *
84
+ * @param {Function} exec
85
+ * @param {{ login: string, type: string }[]} owners
86
+ * @param {string} [appSlug]
87
+ * @returns {Map<string, 'installed'|'not-installed'|'unknown'>}
88
+ */
89
+ export function listAppInstallations(exec = defaultExec, owners = [], appSlug = APP_SLUG) {
90
+ const coverage = new Map();
91
+ const userList = tryExec(exec, "gh", [
92
+ "api", "/user/installations", "--paginate",
93
+ "--jq", ".installations[] | [.app_slug, .account.login] | @tsv",
94
+ ]);
95
+ const covered = new Set();
96
+ if (userList.ok) {
97
+ for (const line of lines(userList.out)) {
98
+ const [slug, login] = line.split("\t");
99
+ if (slug === appSlug && login) covered.add(login.toLowerCase());
100
+ }
101
+ }
102
+
103
+ for (const { login, type } of owners) {
104
+ const key = login.toLowerCase();
105
+ if (covered.has(key)) {
106
+ coverage.set(login, "installed");
107
+ continue;
108
+ }
109
+ if (type === "Organization") {
110
+ const org = tryExec(exec, "gh", [
111
+ "api", `/orgs/${login}/installations`, "--paginate",
112
+ "--jq", ".installations[].app_slug",
113
+ ]);
114
+ if (org.ok) {
115
+ coverage.set(login, lines(org.out).includes(appSlug) ? "installed" : "not-installed");
116
+ continue;
117
+ }
118
+ coverage.set(login, "unknown");
119
+ continue;
120
+ }
121
+ coverage.set(login, userList.ok ? "not-installed" : "unknown");
122
+ }
123
+ return coverage;
124
+ }
125
+
126
+ /**
127
+ * Warning text when the chosen owner is not covered by the App, else "".
128
+ * @param {string} owner
129
+ * @param {'installed'|'not-installed'|'unknown'|undefined} status
130
+ * @returns {string}
131
+ */
132
+ export function ownerCoverageWarning(owner, status) {
133
+ if (status === "installed") return "";
134
+ if (status === "not-installed") {
135
+ return `The ForgeDock GitHub App is not installed on '${owner}'. Pipeline bot actions on this repo will fail (404) until you install it: ${APP_INSTALL_URL}`;
136
+ }
137
+ return `Could not verify whether the ForgeDock GitHub App is installed on '${owner}'. Check, and install it if needed: ${APP_INSTALL_URL}`;
138
+ }
139
+
140
+ function isGitRepo(exec, root) {
141
+ const r = tryExec(exec, "git", ["-C", root, "rev-parse", "--is-inside-work-tree"]);
142
+ return r.ok && r.out === "true";
143
+ }
144
+
145
+ function hasCommits(exec, root) {
146
+ return tryExec(exec, "git", ["-C", root, "rev-parse", "--verify", "--quiet", "HEAD"]).ok;
147
+ }
148
+
149
+ /**
150
+ * Create the GitHub repository and wire it up as `origin`.
151
+ * Runs `git init -b <branch>` first when `root` is not a git repository.
152
+ *
153
+ * @param {Function} exec
154
+ * @param {{ owner: string, name: string, private?: boolean, root: string, branch?: string }} opts
155
+ * @returns {{ fullName: string, visibility: 'private'|'public' }}
156
+ */
157
+ export function createRepo(exec = defaultExec, { owner, name, private: isPrivate = true, root, branch = "main" }) {
158
+ if (!NAME_RE.test(owner || "")) throw new Error(`Invalid GitHub owner: '${owner}'`);
159
+ if (!NAME_RE.test(name || "")) throw new Error(`Invalid repository name: '${name}'`);
160
+ if (!isGitRepo(exec, root)) exec("git", ["init", "-b", branch, root]);
161
+ const visibility = isPrivate === false ? "public" : "private";
162
+ exec("gh", [
163
+ "repo", "create", `${owner}/${name}`,
164
+ `--${visibility}`,
165
+ "--source", root,
166
+ "--remote", "origin",
167
+ ]);
168
+ return { fullName: `${owner}/${name}`, visibility };
169
+ }
170
+
171
+ /**
172
+ * Make an empty initial commit when HEAD is unborn, so `main` can be pushed.
173
+ * Never commits the user's files.
174
+ * @param {Function} exec
175
+ * @param {string} root
176
+ * @returns {{ created: boolean }}
177
+ */
178
+ export function ensureInitialCommit(exec = defaultExec, root) {
179
+ if (hasCommits(exec, root)) return { created: false };
180
+ exec("git", ["-C", root, "commit", "--allow-empty", "-m", "chore: initial commit"]);
181
+ return { created: true };
182
+ }
183
+
184
+ /**
185
+ * Push the default branch and record it as the remote HEAD.
186
+ * @param {Function} exec
187
+ * @param {string} root
188
+ * @param {string} branch
189
+ */
190
+ export function pushMain(exec = defaultExec, root, branch = "main") {
191
+ exec("git", ["-C", root, "push", "-u", "origin", branch]);
192
+ // Best effort: lets detection read the remote default authoritatively.
193
+ tryExec(exec, "git", ["-C", root, "remote", "set-head", "origin", branch]);
194
+ }
195
+
196
+ /**
197
+ * Create `staging` on origin from `from`. No-op when it already exists.
198
+ * @param {Function} exec
199
+ * @param {string} root
200
+ * @param {string} from
201
+ * @returns {{ created: boolean }}
202
+ */
203
+ export function createStaging(exec = defaultExec, root, from = "main") {
204
+ const existing = tryExec(exec, "git", ["-C", root, "ls-remote", "--heads", "origin", "staging"]);
205
+ if (existing.ok && existing.out) {
206
+ tryExec(exec, "git", ["-C", root, "fetch", "origin", "staging"]);
207
+ return { created: false };
208
+ }
209
+ exec("git", ["-C", root, "push", "origin", `${from}:refs/heads/staging`]);
210
+ // Refresh origin/staging so re-detection sees it.
211
+ tryExec(exec, "git", ["-C", root, "fetch", "origin", "staging"]);
212
+ return { created: true };
213
+ }