@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.
- package/dist/behaviour.d.ts +999 -0
- package/dist/behaviour.d.ts.map +1 -0
- package/dist/discovery/fold-import.d.ts +12 -0
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/fold/fold.d.ts +10 -0
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +26 -2
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/identity.d.ts +28 -0
- package/dist/identity.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/lexicon.d.ts +45 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/op/activities/reconcile.d.ts +79 -16
- package/dist/op/activities/reconcile.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/behaviour.test.ts +1961 -0
- package/src/behaviour.ts +1875 -0
- package/src/discovery/fold-import.ts +12 -0
- package/src/fold/fold.ts +10 -0
- package/src/fold/subset-doc-parity.test.ts +60 -0
- package/src/fold/subset-public-export.test.ts +27 -0
- package/src/fold/subset.ts +26 -2
- package/src/identity.ts +31 -2
- package/src/index.ts +6 -0
- package/src/lexicon.ts +46 -0
- package/src/op/activities/reconcile.test.ts +84 -0
- package/src/op/activities/reconcile.ts +90 -22
|
@@ -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
|
+
});
|
package/src/fold/subset.ts
CHANGED
|
@@ -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
|
|
7
|
-
*
|
|
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
|
|
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
|
|
72
|
-
* only `GH_TOKEN
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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
|
|
522
|
-
* is a strict superset of `gh`'s own ambient
|
|
523
|
-
* common case, a named refusal instead of
|
|
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 =
|
|
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
|
|
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
|
|
647
|
-
*
|
|
648
|
-
*
|
|
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 =
|
|
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`;
|