@intentius/chant 0.63.0 → 0.65.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.
@@ -48,6 +48,18 @@ import { intrinsicCallFoldsEagerly, type IntrinsicDef } from "../lexicon";
48
48
  import type { BuildParamValue } from "../build-params";
49
49
 
50
50
  /**
51
+ * Implements judgments J2 (the per-file verdict: F-Scan, F-NoExports, F-Bind,
52
+ * F-Import, F-Namespace, F-Declarator, F-Call, F-Total, F-Reason,
53
+ * F-IsolatedRefusal), J3 (the identity-taint fixpoint: F-Capture, F-CallLeak,
54
+ * F-Memo, F-Count, F-Seed, F-Succ, F-Taint, F-Fix, F-Cycle — see
55
+ * {@link planFoldTaint}), J4's observables (F-Obs-Counters via
56
+ * {@link foldExecutionCounts}, F-Obs-Report via the `[fold:*]` decision
57
+ * lines), and the trust rule F-Host-Trust of `spec/hosts.md` (see
58
+ * {@link isTrustedExecutableBinding}) of the TypeScript-as-Data specification
59
+ * at https://github.com/INTENTIUS/typescript-as-data, normative for the subset
60
+ * since INTENTIUS/typescript-as-data#33. Subset changes go spec-first; see
61
+ * ../fold/subset.ts's module doc for the process.
62
+ *
51
63
  * Bridges the static folder ({@link ../fold/fold}, #1026) into discovery
52
64
  * (#1022/#1023, epic #1019): attempts to fold one source file into real
53
65
  * `Declarable`/`CompositeInstance` instances with zero execution of the
package/src/fold/fold.ts CHANGED
@@ -21,6 +21,16 @@ import { isFoldableHelperName } from "./foldable-helpers";
21
21
  /**
22
22
  * fold — static AST value reducer (chant #1026/#1021/#1024, part of epic #1019)
23
23
  *
24
+ * Implements judgment J1 (`F-Eval-*`, `spec/judgments.md`) and the value domain
25
+ * (`F-Val-*`, `spec/values.md`) of the TypeScript-as-Data specification at
26
+ * https://github.com/INTENTIUS/typescript-as-data, which is normative for the
27
+ * subset since INTENTIUS/typescript-as-data#33. Each branch of {@link fold}
28
+ * below is one F-Eval rule — the identifier branch is F-Eval-Ident, the
29
+ * property-access branch F-Eval-Member (its numbered steps match), the call
30
+ * branch F-Eval-CallHelper / CallIntrinsic / CallLocal / CallEager / CallMethod
31
+ * in that order — and the envelope types here are F-Val-Domain's cases.
32
+ * Subset changes go spec-first; see subset.ts's module doc for the process.
33
+ *
24
34
  * Reduces a single-file TypeScript expression AST to a value with NO
25
35
  * module execution. The node-kind/operator/key subset it covers — literals,
26
36
  * template interpolation, object/array literals (incl. spread), `const`
@@ -64,6 +64,16 @@ import { findSubsetViolation } from "./subset";
64
64
  * comment): moving it under a `###` heading with a fenced block, the shape
65
65
  * every other case here uses, was necessary but not sufficient — the
66
66
  * fixture still has to be run through the right function.
67
+ *
68
+ * Two more claims are checked against `fold()` the same way — chant #2348.
69
+ * "typescript-as-data.mdx" said a method call never folds; #1966 made that
70
+ * false for a method call whose receiver folds to a real value. Both the
71
+ * corrected supported claim ("Method calls on folded values") and the
72
+ * corrected unsupported one ("An array method whose callback is a function
73
+ * value" — right outcome, `list.map(...)` still falls back, but because the
74
+ * callback is a function used as a value, not because `.map` is a method
75
+ * call) were previously unfenced prose sentences too, invisible to this file
76
+ * for the same reason #2306's was.
67
77
  */
68
78
 
69
79
  const repoRoot = fileURLToPath(new URL("../../../../", import.meta.url));
@@ -193,6 +203,17 @@ describe("subset-doc-parity — supported patterns in typescript-as-data.mdx cla
193
203
  if (!wrapped) throw new Error("subset-doc-parity: nullish-coalescing fragment failed to parse");
194
204
  expect(findSubsetViolation(wrapped)).toBeUndefined();
195
205
  });
206
+
207
+ test("Method calls on folded values", () => {
208
+ // chant #1966/#2348 — findSubsetViolation accepts this shape
209
+ // unconditionally regardless of whether the receiver actually resolves
210
+ // (module doc, point above the CallExpression case in ./subset.ts):
211
+ // shape-valid here is necessary but not sufficient. The real
212
+ // accept/reject decision is fold()'s, checked below in the
213
+ // fold()-decided describe block.
214
+ const consts = parseConsts(extractFencedBlock("Method calls on folded values"));
215
+ expect(findSubsetViolation(resourceArg(consts, "store"))).toBeUndefined();
216
+ });
196
217
  });
197
218
 
198
219
  describe("subset-doc-parity — unsupported patterns in typescript-as-data.mdx classify as rejected", () => {
@@ -221,6 +242,14 @@ describe("subset-doc-parity — unsupported patterns in typescript-as-data.mdx c
221
242
  const consts = parseConsts(extractFencedBlock("Spread from dynamic sources"));
222
243
  expect(findSubsetViolation(resourceArg(consts, "store"))).toBeDefined();
223
244
  });
245
+
246
+ test("An array method whose callback is a function value", () => {
247
+ // chant #2348 — the callback argument, an ArrowFunction, is what
248
+ // findSubsetViolation rejects here (falls through to the catch-all
249
+ // unsupportedExpressionMessage), not the `.map(...)` method call itself.
250
+ const consts = parseConsts(extractFencedBlock("An array method whose callback is a function value"));
251
+ expect(findSubsetViolation(resourceArg(consts, "store"))).toBeDefined();
252
+ });
224
253
  });
225
254
 
226
255
  describe("subset-doc-parity — fold()-decided claim in typescript-as-data.mdx (#2306)", () => {
@@ -242,3 +271,34 @@ describe("subset-doc-parity — fold()-decided claim in typescript-as-data.mdx (
242
271
  expect((error as FoldError).message).toContain("unknownTag");
243
272
  });
244
273
  });
274
+
275
+ describe("subset-doc-parity — fold()-decided claims in typescript-as-data.mdx (#2348)", () => {
276
+ test("Method calls on folded values", () => {
277
+ // findSubsetViolation only proves the shape is admissible (above); prove
278
+ // the doc's own example actually folds, and to the value the doc claims,
279
+ // by running it through fold() with no externals at all — the receiver
280
+ // (`[prefix, "data"]`) is a plain array literal, so nothing needs to
281
+ // resolve across a file boundary for this one.
282
+ const consts = parseConsts(extractFencedBlock("Method calls on folded values"));
283
+ const arg = resourceArg(consts, "store");
284
+ expect(fold(arg, consts, [])).toEqual({ name: "acct-data" });
285
+ });
286
+
287
+ test("An array method whose callback is a function value — real reason", () => {
288
+ // The doc's corrected claim: this falls back because the ARROW FUNCTION
289
+ // is a value fold refuses, not because `.map` is a method call. Assert
290
+ // the actual FoldError says so, rather than something naming ".map" or
291
+ // "method call".
292
+ const consts = parseConsts(extractFencedBlock("An array method whose callback is a function value"));
293
+ const arg = resourceArg(consts, "store");
294
+ let error: unknown;
295
+ try {
296
+ fold(arg, consts, []);
297
+ } catch (e) {
298
+ error = e;
299
+ }
300
+ expect(error).toBeInstanceOf(FoldError);
301
+ expect((error as FoldError).message).toContain("a function used as a value is not foldable");
302
+ expect((error as FoldError).message).not.toContain("method call");
303
+ });
304
+ });
@@ -0,0 +1,27 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import * as ts from "typescript";
3
+ import * as chant from "../index";
4
+
5
+ /**
6
+ * The shape classifier is part of the public entry so a conformance adapter
7
+ * (INTENTIUS/typescript-as-data#11) and downstream tooling can ask "will this
8
+ * fold?" without running a fold. This pins the export and its two answers.
9
+ */
10
+ describe("findSubsetViolation is exported from the package entry", () => {
11
+ const initializerOf = (src: string) => {
12
+ const sf = ts.createSourceFile("x.ts", src, ts.ScriptTarget.Latest, true);
13
+ return (sf.statements[0] as ts.VariableStatement).declarationList.declarations[0].initializer!;
14
+ };
15
+ test("is a function on the public namespace", () => {
16
+ expect(typeof chant.findSubsetViolation).toBe("function");
17
+ expect(typeof chant.checkObjectMember).toBe("function");
18
+ });
19
+ test("classifies a call as EVL001 and a literal as clean", () => {
20
+ const call = chant.findSubsetViolation(initializerOf("export const x = getId();"));
21
+ expect(call?.ruleId).toBe("EVL001");
22
+ expect(chant.findSubsetViolation(initializerOf('export const x = "ok";'))).toBeUndefined();
23
+ });
24
+ test("classifies a dynamic element-access key as EVL003", () => {
25
+ expect(chant.findSubsetViolation(initializerOf("export const x = cfg[key];"))?.ruleId).toBe("EVL003");
26
+ });
27
+ });
@@ -3,8 +3,32 @@ import { isFoldableHelperName } from "./foldable-helpers";
3
3
  import { intrinsicCallFolds, intrinsicCallFoldsEagerly, type IntrinsicDef } from "../lexicon";
4
4
 
5
5
  /**
6
- * subset — the single canonical definition of chant's statically-foldable
7
- * expression subset (chant #1024, epic #1019).
6
+ * subset — chant's implementation of the SHAPE layer of the TypeScript-as-Data
7
+ * specification (chant #1024, epic #1019).
8
+ *
9
+ * ## The specification is normative; this file implements it
10
+ *
11
+ * Since INTENTIUS/typescript-as-data#33 (2026-09-10) the subset is defined by
12
+ * the specification at https://github.com/INTENTIUS/typescript-as-data, not by
13
+ * this file. The rules this module implements are the `S-*` productions of
14
+ * `spec/grammar.md` §2 — S-Unwrap, S-Literal, S-Ident, S-Template, S-Tagged,
15
+ * S-Object (S-Prop / S-Shorthand / S-SpreadProp), S-Array, S-Member, S-Index,
16
+ * S-Unary, S-Binary, S-Conditional, S-New, the S-Call forms (S-CallHelper,
17
+ * S-CallIntrinsic, S-CallEager, S-CallMethod, S-CompositeStep) and S-Reject —
18
+ * and the direction rule `F-Direction` of `spec/divergence.md`: this classifier
19
+ * may accept what `fold()` rejects, never the reverse, outside the two named
20
+ * exceptions F-Exc-Lazy and F-Exc-Registry. The "environment-dependent
21
+ * exceptions" enumerated below are `spec/divergence.md`'s F-Div-* rows, kept
22
+ * here as implementation notes on WHY each resolution is out of this module's
23
+ * reach.
24
+ *
25
+ * Changing the subset goes spec-first: propose and land the rule there (with a
26
+ * fixture), then implement it here citing the identifier, then release. The
27
+ * provisional path for a change needed before the rule can be written: land it
28
+ * with the affected rule marked PROVISIONAL in this doc naming the spec issue;
29
+ * a provisional marker may survive at most one release, and the docs may not
30
+ * describe the change as supported until the rule exists. See
31
+ * `spec/README.md` "Ownership" and chant #2354.
8
32
  *
9
33
  * `fold()` ({@link "./fold"}, the enforcement layer — a construct outside
10
34
  * this subset simply has no case there) and EVL001/EVL003
package/src/identity.ts CHANGED
@@ -161,7 +161,7 @@ export const REDACTED = "[redacted]";
161
161
  * name, so a lexicon that echoes one of these into an identity string has the
162
162
  * value removed before it is printed or serialized.
163
163
  */
164
- const CREDENTIAL_ENV_NAME =
164
+ export const CREDENTIAL_ENV_NAME =
165
165
  /(SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL|PRIVATE_KEY|APIKEY|API_KEY|ACCESS_KEY|SESSION_KEY|AUTH)/i;
166
166
 
167
167
  /** Shortest env value worth redacting. Below this a "secret" is a false positive. */
@@ -171,7 +171,7 @@ const MIN_CREDENTIAL_LENGTH = 8;
171
171
  * Literal credential shapes. Each is something a principal string cannot be,
172
172
  * so matching one is proof rather than a guess.
173
173
  */
174
- const CREDENTIAL_SHAPES: RegExp[] = [
174
+ export const CREDENTIAL_SHAPES: RegExp[] = [
175
175
  // A PEM block of any key type.
176
176
  /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
177
177
  // A JWT: three base64url segments, the first starting with the `{"` header.
@@ -180,6 +180,32 @@ const CREDENTIAL_SHAPES: RegExp[] = [
180
180
  /\b(?:Bearer|Basic)\s+[A-Za-z0-9\-._~+/]{16,}={0,2}/g,
181
181
  ];
182
182
 
183
+ /**
184
+ * Provider-prefixed access tokens, by their issuer's own prefix (#2356).
185
+ *
186
+ * Separate from {@link CREDENTIAL_SHAPES} because these are a **denylist of
187
+ * known formats** rather than proof-by-shape. A prefix nobody has added here —
188
+ * a new provider, an internal issuer, a bare random string — passes every one
189
+ * of them, and any caller relying on this must say so rather than claim
190
+ * coverage. What it does buy is that the tokens people actually paste are
191
+ * caught wherever they appear, whatever the field is called.
192
+ *
193
+ * The suffix bound is deliberately short. The prefix is the signal; a truncated
194
+ * or example token is still somebody having written a credential down.
195
+ */
196
+ export const CREDENTIAL_TOKEN_SHAPES: { name: string; re: RegExp }[] = [
197
+ { name: "a GitHub token", re: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{6,}\b/g },
198
+ { name: "a GitHub fine-grained token", re: /\bgithub_pat_[A-Za-z0-9_]{6,}\b/g },
199
+ { name: "a GitLab personal access token", re: /\bglpat-[A-Za-z0-9_-]{3,}\b/g },
200
+ { name: "an OpenAI-style secret key", re: /\bsk-(?:live-|proj-|test-)?[A-Za-z0-9]{6,}\b/g },
201
+ { name: "a Stripe key", re: /\b[rs]k_(?:live|test)_[A-Za-z0-9]{6,}\b/g },
202
+ { name: "a Slack token", re: /\bxox[abposr]-[A-Za-z0-9-]{6,}\b/g },
203
+ { name: "an AWS access key id", re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
204
+ { name: "a Google API key", re: /\bAIza[0-9A-Za-z_-]{20,}\b/g },
205
+ { name: "a Google OAuth token", re: /\bya29\.[0-9A-Za-z_-]{10,}/g },
206
+ { name: "an npm token", re: /\bnpm_[A-Za-z0-9]{10,}\b/g },
207
+ ];
208
+
183
209
  /**
184
210
  * Strip credential material from one reported field.
185
211
  *
@@ -205,6 +231,9 @@ export function redactCredentialMaterial(
205
231
  out = out.split(secret).join(REDACTED);
206
232
  }
207
233
  for (const shape of CREDENTIAL_SHAPES) out = out.replace(shape, REDACTED);
234
+ // Provider-prefixed tokens too (#2356): these are the ones people paste, and
235
+ // the three shapes above match none of them.
236
+ for (const { re } of CREDENTIAL_TOKEN_SHAPES) out = out.replace(re, REDACTED);
208
237
  return out;
209
238
  }
210
239
 
package/src/index.ts CHANGED
@@ -39,6 +39,11 @@ export * from "./graph-layout";
39
39
  export * from "./graph-lens";
40
40
  export * from "./detectLexicon";
41
41
  export * from "./fold/fold";
42
+ // The shape classifier is the predicate downstream tooling asks "will this
43
+ // fold?" of without running a fold (subset.ts module doc, point 2c), and the
44
+ // half of the fold subset a conformance adapter needs that `fold()` alone
45
+ // does not expose. INTENTIUS/typescript-as-data#11.
46
+ export { findSubsetViolation, checkObjectMember, type SubsetViolation, type SubsetRuleId } from "./fold/subset";
42
47
  export * from "./lint/parser";
43
48
  export * from "./lint/rule";
44
49
  export * from "./lint/rules";
@@ -57,6 +62,7 @@ export * from "./observation";
57
62
  export * from "./identity";
58
63
  export * from "./apply";
59
64
  export * from "./deep-observation";
65
+ export * from "./behaviour";
60
66
  export * from "./claimed-fields";
61
67
  export * from "./fold-provenance";
62
68
  export * from "./owner-chain";
package/src/lexicon.ts CHANGED
@@ -23,6 +23,7 @@ import type { IREdge } from "./graph-ir";
23
23
  import type { DescribeResourcesResult, UnobservedReason } from "./observation";
24
24
  import type { DescribeIdentityOptions, DescribeIdentityResult } from "./identity";
25
25
  import type { DeepNormalizationHooks, DeepObservationResult } from "./deep-observation";
26
+ import type { BehaviourResult, PredictBehaviourOptions } from "./behaviour";
26
27
  import type { DisruptionQuery, DisruptionVerdict } from "./lifecycle/disruption";
27
28
  import type { OwnerChainVerdict } from "./owner-chain";
28
29
  import type { CommandGroup } from "./cli/command-group";
@@ -1413,6 +1414,51 @@ export interface LexiconPlugin {
1413
1414
  */
1414
1415
  deepNormalizationHooks?: DeepNormalizationHooks;
1415
1416
 
1417
+ /**
1418
+ * Predict what the declared estate would *do* at a stated traffic level
1419
+ * (#2356) — cost per hour, headroom, an error-rate expectation, a resilience
1420
+ * verdict under a named failure, and a right-size hint, per entity, every
1421
+ * figure carrying its provenance. Opt-in, and the fourth member of the
1422
+ * observation family beside {@link describeResources}, {@link
1423
+ * observeResourcesDeep} and {@link listArtifacts}.
1424
+ *
1425
+ * It is the odd one out in that family, and the type says so. The other three
1426
+ * report what a substrate was asked and answered. This one reports what an
1427
+ * engine believes would happen at a level nobody has run yet, so it is
1428
+ * `predict`, not `observe`, and its result can never be read as a
1429
+ * measurement or a bill: money exists only as a `PredictedRate` for one
1430
+ * imagined hour, every entity states the `at` its figures answer, and
1431
+ * `provenance.basis` says `modeled` or `validated` on every one of them. See
1432
+ * `../behaviour.ts` for the full argument.
1433
+ *
1434
+ * Options mirror {@link observeResourcesDeep}'s field for field, plus
1435
+ * `traffic` — a caller already driving the deep read drives this with the
1436
+ * same object. What the options deliberately cannot carry is a credential:
1437
+ * every name for one is declared `?: never`, because the engine is handed the
1438
+ * resource graph and nothing else, reaches no account, and writes nothing.
1439
+ *
1440
+ * Three verdicts per entity, on the tri-state discipline #1089 established
1441
+ * and a stricter total: PREDICTED (a key in `entities`),
1442
+ * NOT-PREDICTABLE-FOR-THIS-KIND (`unpredicted` with `unsupported-kind` — an
1443
+ * engine with no model for a kind says so and never returns zero), and
1444
+ * NOT-PREDICTED (`unpredicted` with another reason). Every name the caller
1445
+ * asked about lands in one map or the other; unlike the thin read there is no
1446
+ * third position, because a prediction has no equivalent of "the provider
1447
+ * says it is not there".
1448
+ *
1449
+ * A missing or unreachable engine is a `BehaviourRefusalReport` — the other
1450
+ * arm of the result union, with no `entities` map to be empty and no total to
1451
+ * be zero — carrying a named cause and a message that names the variable it
1452
+ * wanted, in the style of `noGitlabNoteTokenMessage`
1453
+ * (`./op/activities/reconcile.ts`). Build one with
1454
+ * `noBehaviourEngineRefusal` / `unreachableBehaviourEngineRefusal` rather
1455
+ * than by hand.
1456
+ *
1457
+ * Throwing is the whole-lexicon failure, same as the other reads. Prefer the
1458
+ * refusal: it says which variable, and a stack trace does not.
1459
+ */
1460
+ predictBehaviour?(options: PredictBehaviourOptions): Promise<BehaviourResult>;
1461
+
1416
1462
  /**
1417
1463
  * Report the live status of one deploy unit by its deployed name. Opt-in.
1418
1464
  *
@@ -18,6 +18,7 @@ import {
18
18
  suppliedMarker,
19
19
  noIssueTokenMessage,
20
20
  postOrUpdateGithubIssue,
21
+ ghCredentialEnv,
21
22
  } from "./reconcile";
22
23
 
23
24
  // ── The `gh` stub (chant #2291) ──────────────────────────────────────────────
@@ -251,6 +252,32 @@ describe("commentTokenFrom (#2291)", () => {
251
252
  });
252
253
  });
253
254
 
255
+ describe("ghCredentialEnv (#2333)", () => {
256
+ const token = { value: "resolved-token", source: "CHANT_FORGEJO_TOKEN" };
257
+
258
+ test("carries the resolved value under GH_ENTERPRISE_TOKEN, the variable a non-github.com host reads", () => {
259
+ expect(ghCredentialEnv({}, token).GH_ENTERPRISE_TOKEN).toBe("resolved-token");
260
+ });
261
+
262
+ test("carries it under GH_TOKEN too, so github.com reads the same value it always did", () => {
263
+ expect(ghCredentialEnv({}, token).GH_TOKEN).toBe("resolved-token");
264
+ });
265
+
266
+ test("the two never disagree — one resolution, whichever class gh puts the host in", () => {
267
+ const env = ghCredentialEnv({ GH_TOKEN: "stale-ambient" }, token);
268
+ expect(env.GH_TOKEN).toBe(env.GH_ENTERPRISE_TOKEN);
269
+ });
270
+
271
+ test("sets no GH_HOST: the full URL already names the host, and GH_HOST is the default for calls that do not", () => {
272
+ expect(ghCredentialEnv({}, token)).not.toHaveProperty("GH_HOST");
273
+ });
274
+
275
+ test("passes the rest of the base environment through untouched", () => {
276
+ expect(ghCredentialEnv({ PATH: "/usr/bin", GITHUB_API_URL: "http://forgejo.example/api/v1" }, token))
277
+ .toMatchObject({ PATH: "/usr/bin", GITHUB_API_URL: "http://forgejo.example/api/v1" });
278
+ });
279
+ });
280
+
254
281
  describe("reconcilePr comment mode posts a full-URL `gh api` call (#2291)", () => {
255
282
  const repo = "acme/infra";
256
283
 
@@ -341,6 +368,43 @@ describe("reconcilePr comment mode posts a full-URL `gh api` call (#2291)", () =
341
368
  }
342
369
  });
343
370
 
371
+ // #2333: the full URL #2291 built reached Forgejo, but `gh` scopes
372
+ // GH_TOKEN to github.com and ghe.com subdomains, so the POST/PATCH arrived
373
+ // with no Authorization header and a real instance answered 401.
374
+ test("every comment-mode `gh` call carries GH_ENTERPRISE_TOKEN, which is what a Forgejo host reads (#2333)", async () => {
375
+ stubPrEnv("http://forgejo.example/api/v1");
376
+ vi.stubEnv("CHANT_FORGEJO_TOKEN", "cross-instance-token");
377
+ ghReplies = [
378
+ { match: "--paginate", stdout: "" },
379
+ { match: "--method POST", stdout: "http://forgejo.example/acme/infra/issues/5#issuecomment-1\n" },
380
+ ];
381
+ try {
382
+ await reconcilePr({ env: "app", mode: "comment", body: "the plan" });
383
+ expect(ghCalls.length).toBeGreaterThan(0);
384
+ for (const call of ghCalls) {
385
+ expect(call.opts.env?.GH_ENTERPRISE_TOKEN).toBe("cross-instance-token");
386
+ }
387
+ } finally {
388
+ vi.unstubAllEnvs();
389
+ }
390
+ });
391
+
392
+ test("the PATCH branch carries it too, not only the POST (#2333)", async () => {
393
+ stubPrEnv("http://forgejo.example/api/v1");
394
+ vi.stubEnv("CHANT_FORGEJO_TOKEN", "cross-instance-token");
395
+ ghReplies = [
396
+ { match: "--paginate", stdout: "4242\n" },
397
+ { match: "--method PATCH", stdout: "http://forgejo.example/acme/infra/issues/5#issuecomment-1\n" },
398
+ ];
399
+ try {
400
+ await reconcilePr({ env: "app", mode: "comment", body: "the plan" });
401
+ const patch = ghCalls.find((c) => c.cmd.includes("--method PATCH"));
402
+ expect(patch?.opts.env?.GH_ENTERPRISE_TOKEN).toBe("cross-instance-token");
403
+ } finally {
404
+ vi.unstubAllEnvs();
405
+ }
406
+ });
407
+
344
408
  test("a pull request with no token at all is refused by name, before any `gh` call", async () => {
345
409
  stubPrEnv("http://forgejo.example/api/v1");
346
410
  vi.stubEnv("GH_TOKEN", "");
@@ -1292,6 +1356,26 @@ describe("reconcilePr issue mode sends the token it resolved (#2320)", () => {
1292
1356
  }
1293
1357
  });
1294
1358
 
1359
+ // #2333: the same credential hole comment mode had. Issue mode's writes
1360
+ // were already refused by the forgejo generator for exactly this reason.
1361
+ test("every issue-mode `gh` call carries GH_ENTERPRISE_TOKEN as well (#2333)", async () => {
1362
+ stubForgejoIssueEnv();
1363
+ vi.stubEnv("CHANT_FORGEJO_TOKEN", "forgejo-cross-instance");
1364
+ ghReplies = [
1365
+ { match: "--paginate", stdout: "" },
1366
+ { match: "--method POST", stdout: "https://other.forgejo.example/acme/infra/issues/1\n" },
1367
+ ];
1368
+ try {
1369
+ await reconcilePr({ env: "app", op: "nightly", mode: "issue", body: "plan" });
1370
+ expect(ghCalls).toHaveLength(2);
1371
+ for (const call of ghCalls) {
1372
+ expect(call.opts.env?.GH_ENTERPRISE_TOKEN).toBe("forgejo-cross-instance");
1373
+ }
1374
+ } finally {
1375
+ vi.unstubAllEnvs();
1376
+ }
1377
+ });
1378
+
1295
1379
  test("falls back to GH_TOKEN then GITHUB_TOKEN, the same order comment mode uses", async () => {
1296
1380
  stubForgejoIssueEnv();
1297
1381
  vi.stubEnv("CHANT_FORGEJO_TOKEN", "");
@@ -65,18 +65,22 @@ const execAsync = promisify(exec);
65
65
  * half works — plain paginated listing (no `/search/issues`, confirmed
66
66
  * absent from Forgejo's own OpenAPI spec) and the `.pull_request == null`
67
67
  * filter both behave exactly as they do against github.com. The *write*
68
- * half does not, for a reason no mock could have caught: `gh`'s own
68
+ * half did not, for a reason no mock could have caught: `gh`'s own
69
69
  * `GH_TOKEN`/`GITHUB_TOKEN` only authenticate a request to github.com or a
70
70
  * ghe.com subdomain (`gh help environment`), never a self-hosted Forgejo,
71
- * and this function's POST/PATCH calls (like `postOrUpdateComment`'s) carry
72
- * only `GH_TOKEN` — no `GH_HOST`, no `GH_ENTERPRISE_TOKEN`. Every write this
73
- * mode makes on a real Forgejo instance fails with `{"message":"token is
74
- * required"}` (HTTP 401), confirmed with `GH_DEBUG=api` sending no
75
- * `Authorization` header at all. The forgejo Op generator refuses
76
- * `findingMode: "issue"` by name for this reason (chant #2315); this
77
- * activity's own behavior is unchanged; a hand-authored (non-generated)
78
- * workflow that calls it directly against a Forgejo host will hit the same
79
- * 401 the generator now refuses to produce.
71
+ * and both this function's POST/PATCH calls and `postOrUpdateComment`'s
72
+ * carried only `GH_TOKEN`. Every write either made on a real Forgejo
73
+ * instance failed with `{"message":"token is required"}` (HTTP 401), with
74
+ * `GH_DEBUG=api` showing no `Authorization` header at all — which is what
75
+ * shipped in 0.62.0 and 0.63.0, `comment` mode included.
76
+ *
77
+ * Both paths now forward that credential as `GH_ENTERPRISE_TOKEN` as well as
78
+ * `GH_TOKEN`, which is the variable `gh` reads for a host in neither of those
79
+ * two classes (chant #2333). Re-run against the same live instance from a
80
+ * shell with no stored `gh auth login`, the identical POST now sends
81
+ * `Authorization: token …` and returns HTTP 201. See {@link ghCredentialEnv}
82
+ * for the whole probe, for why `GH_HOST` turned out not to be needed
83
+ * alongside it, and for why github.com's own behavior is untouched.
80
84
  */
81
85
  export type ReconcileMode = "pull-request" | "issue" | "report" | "comment";
82
86
 
@@ -485,6 +489,69 @@ export function commentTokenFrom(env: Record<string, string | undefined>): Comme
485
489
  return undefined;
486
490
  }
487
491
 
492
+ /**
493
+ * The environment a `gh api` call carries {@link commentTokenFrom}'s resolved
494
+ * credential in (chant #2333).
495
+ *
496
+ * `GH_TOKEN` alone is not enough, and that is `gh`'s documented behavior
497
+ * rather than a bug in it. `gh` picks the token per *request host*:
498
+ * `GH_TOKEN`/`GITHUB_TOKEN` are scoped to github.com and `ghe.com`
499
+ * subdomains, and every other host — a self-hosted Forgejo, a self-hosted
500
+ * GitHub Enterprise Server — reads `GH_ENTERPRISE_TOKEN`/
501
+ * `GITHUB_ENTERPRISE_TOKEN` instead (`gh help environment`). #2291 fixed the
502
+ * *URL* half of reaching a non-github.com host and left this half untouched,
503
+ * so 0.62.0 and 0.63.0 both shipped a `comment` mode that posts to Forgejo
504
+ * with no `Authorization` header at all.
505
+ *
506
+ * Confirmed against a real Forgejo 12.0.4+gitea-1.22.0 instance under #2333,
507
+ * from a shell whose `GH_CONFIG_DIR` held no `gh auth login` — the condition
508
+ * a fresh Actions checkout starts from, and the one #2304's verification did
509
+ * not reproduce. Driving this function's own POST with `GH_DEBUG=api`:
510
+ * `GH_TOKEN` alone sent no `Authorization` header and got HTTP 401
511
+ * `{"message":"token is required"}`, `GH_TOKEN` with `GITHUB_TOKEN` beside it
512
+ * got the same 401, and `GH_TOKEN` with `GH_HOST` beside it got the same 401
513
+ * again. `GH_ENTERPRISE_TOKEN` sent `Authorization: token …` and got HTTP 201.
514
+ *
515
+ * So the fix is one variable, set to the same value: whichever class `gh`
516
+ * decides the host falls into, the credential it finds there is the one
517
+ * {@link commentTokenFrom} resolved. That is why this does not branch on the
518
+ * forge, and why it needs no host detection of chant's own — the thing that
519
+ * broke was `gh`'s host classification, and setting both classes to one value
520
+ * is what stops chant depending on it.
521
+ *
522
+ * ## Why `GH_HOST` is deliberately not set
523
+ *
524
+ * #2333 proposed `GH_ENTERPRISE_TOKEN` *plus* a `GH_HOST` derived from
525
+ * `GITHUB_API_URL`, on the reading that the pair was needed. It is not: the
526
+ * live instance took `GH_ENTERPRISE_TOKEN` on its own, with no `GH_HOST`
527
+ * anywhere in the environment, because {@link githubApiBaseFrom} already
528
+ * hands `gh api` a full URL and `gh` reads the host off that URL rather than
529
+ * off its default. `GH_HOST` sets the *default* host for calls that name no
530
+ * host — which is what `gh issue create`'s ambient fallback below relies on —
531
+ * so setting it would buy nothing here and put a variable into the
532
+ * environment of calls that do not want one.
533
+ *
534
+ * ## Why github.com is unchanged
535
+ *
536
+ * Both variables carry the same value, so the question is only which one
537
+ * `gh` reads, and github.com reads `GH_TOKEN`. Probed against the real
538
+ * api.github.com the same way: a valid `GH_TOKEN` with a deliberately
539
+ * garbage `GH_ENTERPRISE_TOKEN` beside it succeeded, and a garbage `GH_TOKEN`
540
+ * with a valid `GH_ENTERPRISE_TOKEN` beside it failed `Bad credentials`.
541
+ * `GH_TOKEN` wins on github.com whatever the enterprise variable holds, so
542
+ * the header a github.com call sends is byte-identical to the one it sent
543
+ * before this change.
544
+ *
545
+ * A *self-hosted* GHES host is in the same class as Forgejo and was equally
546
+ * broken — same `gh` code path, same missing header — so it is fixed by the
547
+ * same variable rather than merely preserved. That was inferred from `gh`'s
548
+ * documented scoping in #2332 and is not exercised here: no GHES instance was
549
+ * available to test against.
550
+ */
551
+ export function ghCredentialEnv(base: NodeJS.ProcessEnv, token: CommentToken): NodeJS.ProcessEnv {
552
+ return { ...base, GH_TOKEN: token.value, GH_ENTERPRISE_TOKEN: token.value };
553
+ }
554
+
488
555
  /** What a `comment`-mode step says on a pull request it has no credential for. */
489
556
  export function noCommentTokenMessage(repo: string, number: number): string {
490
557
  return (
@@ -518,10 +585,12 @@ export function noIssueTokenMessage(repo: string): string {
518
585
  * Every call targets a full URL built from {@link githubApiBaseFrom} rather
519
586
  * than the bare relative path this used before #2291 — see that function for
520
587
  * why a bare path broke Forgejo specifically. The token is resolved
521
- * explicitly via {@link commentTokenFrom} and forwarded as `GH_TOKEN`, which
522
- * is a strict superset of `gh`'s own ambient resolution: same value in the
523
- * common case, a named refusal instead of `gh`'s opaque 401 when neither is
524
- * set.
588
+ * explicitly via {@link commentTokenFrom} and forwarded through {@link
589
+ * ghCredentialEnv}, which is a strict superset of `gh`'s own ambient
590
+ * resolution: same value in the common case, a named refusal instead of
591
+ * `gh`'s opaque 401 when neither is set, and — since chant #2333 — the same
592
+ * value under `GH_ENTERPRISE_TOKEN` too, without which the full URL #2291
593
+ * built reached Forgejo carrying no credential.
525
594
  */
526
595
  async function postOrUpdateComment(
527
596
  ctx: PullRequestContext,
@@ -531,7 +600,7 @@ async function postOrUpdateComment(
531
600
  ): Promise<string> {
532
601
  const token = commentTokenFrom(process.env);
533
602
  if (!token) throw new Error(noCommentTokenMessage(ctx.repo, ctx.number));
534
- const env = { ...process.env, GH_TOKEN: token.value };
603
+ const env = ghCredentialEnv(process.env, token);
535
604
 
536
605
  const base = githubApiBaseFrom(process.env);
537
606
  const listUrl = `${base}/repos/${ctx.repo}/issues/${ctx.number}/comments`;
@@ -598,8 +667,8 @@ export type GhExec = (
598
667
  * Server the same way it reaches github.com.
599
668
  *
600
669
  * The credential is resolved and forwarded the same way too (chant #2320),
601
- * through {@link commentTokenFrom} and out as `GH_TOKEN` on every call. It
602
- * did not used to be: `reconcilePr` handed this `(cmd) => execAsync(cmd, {
670
+ * through {@link commentTokenFrom} and out through {@link ghCredentialEnv} on
671
+ * every call. It did not used to be: `reconcilePr` handed this `(cmd) => execAsync(cmd, {
603
672
  * signal })` with no `env` at all, so `CHANT_FORGEJO_TOKEN` never reached
604
673
  * `gh` and the one case that variable exists for — posting to a Forgejo
605
674
  * instance other than the one the job runs on, where `github.token`'s scope
@@ -643,10 +712,9 @@ export type GhExec = (
643
712
  * open` on a real Forgejo 12.0.4+gitea-1.22.0 instance interleaves pull
644
713
  * requests with issues exactly as GitHub's endpoint does, and the
645
714
  * `.pull_request == null` filter below excludes them correctly. The write
646
- * calls this function makes do not clear the same instance — see the
647
- * module doc's `issue` bullet for why, and why the forgejo Op generator
648
- * refuses this mode rather than generating a job that would 401 on every
649
- * run.
715
+ * calls this function makes did not clear the same instance until chant
716
+ * #2333 gave them a credential `gh` would apply to that host — see the
717
+ * module doc's `issue` bullet and {@link ghCredentialEnv}.
650
718
  *
651
719
  * So this reuses the recipe `postOrUpdateComment` already proved for PR
652
720
  * comments: list, `--paginate`, filter with `--jq` by an exact `startswith`
@@ -686,7 +754,7 @@ export async function postOrUpdateGithubIssue(
686
754
  ): Promise<string> {
687
755
  const token = commentTokenFrom(process.env);
688
756
  if (!token) throw new Error(noIssueTokenMessage(repo));
689
- const env = { ...process.env, GH_TOKEN: token.value };
757
+ const env = ghCredentialEnv(process.env, token);
690
758
 
691
759
  const base = githubApiBaseFrom(process.env);
692
760
  const listUrl = `${base}/repos/${repo}/issues?state=open`;