mandrel-platform 1.8.1 → 1.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel-platform",
3
- "version": "1.8.1",
3
+ "version": "1.9.0",
4
4
  "description": "Shared CI/deploy workflows, composite toolchain action, npm config package, Renovate preset, and operator runbook templates.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -131,7 +131,7 @@ export const KEY_SCHEMA = Object.freeze({
131
131
  kind: "'var' | 'secret'",
132
132
  sensitivity: "'public' | 'secret'",
133
133
  residency:
134
- "object — {local: 'var'|'secret'|'file'|null, github: {scope,kind}|null, cloudflare: {workers,kind}|null}",
134
+ "object — {local: 'var'|'secret'|'file'|null, github: G|G[]|null where G = {scope,kind,environments?}, cloudflare: {workers,kind}|null}",
135
135
  infisical: "{folder, environments} | 'unmanaged'",
136
136
  shape: `string? — one of ${SHAPE_NAMES.join(", ")}`,
137
137
  placeholderPattern: "string? — literal, case-insensitive substring marking an unset placeholder value",
@@ -181,6 +181,96 @@ export function parseManifest(raw) {
181
181
  return { environments: [...environments], workers, keys };
182
182
  }
183
183
 
184
+ /**
185
+ * Normalize `residency.github` to its canonical array form.
186
+ *
187
+ * The field accepts EITHER a single `{scope, kind}` object — the shape every
188
+ * manifest written before Story #459 uses — or an array of them, because two
189
+ * residencies that occur in practice cannot be said with one object:
190
+ *
191
+ * 1. **Dual scope.** A key can legitimately live at repository level AND at
192
+ * environment level, for different consumers: a deploy job declares
193
+ * `environment:` and reads the per-environment value while a CI job
194
+ * declares none and reads a repo-level credential of the same name.
195
+ * Under a single object, whichever scope the manifest declared, the other
196
+ * reported as an orphan — permanently, on a healthy repo.
197
+ * 2. **Per-environment presence.** A key can be deliberately present in one
198
+ * environment and absent from another (a production-only analytics token
199
+ * whose staging counterpart is meant to resolve empty). With no
200
+ * per-entry `environments`, the absence read as a `missing` failure.
201
+ *
202
+ * Both authored shapes normalize to one array of `{scope, kind, environments}`
203
+ * with `environments` defaulted and materialized to `manifest.environments`,
204
+ * so `probeGitHub` has exactly one shape to read — the same normalize-at-parse
205
+ * treatment `infisical.environments` already receives.
206
+ *
207
+ * Validation fails closed, matching this module's posture on an unknown
208
+ * `shape`: silently ignoring a misplaced or misspelled `environments` is how a
209
+ * manifest author comes to believe they have scoped something they have not.
210
+ *
211
+ * @param {unknown} raw
212
+ * @param {{at: string, name: string, environments: string[]}} ctx
213
+ * @returns {Array<{scope: string, kind: string, environments: string[]}> | null}
214
+ */
215
+ function normalizeGitHubResidency(raw, { at, name, environments }) {
216
+ if (raw === undefined || raw === null) return null;
217
+
218
+ const authoredAsArray = Array.isArray(raw);
219
+ const entries = authoredAsArray ? raw : [raw];
220
+ if (entries.length === 0) {
221
+ throw new Error(
222
+ `${at}.residency.github must not be an empty array — use null when the key does not belong in GitHub (key ${name})`
223
+ );
224
+ }
225
+
226
+ const seenPairs = new Set();
227
+ return entries.map((entry, j) => {
228
+ const where = authoredAsArray ? `${at}.residency.github[${j}]` : `${at}.residency.github`;
229
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
230
+ throw new Error(`${where} must be an object with {scope, kind} (key ${name})`);
231
+ }
232
+ if (entry.scope !== "environment" && entry.scope !== "repository") {
233
+ throw new Error(`${where}.scope must be "environment" or "repository" (key ${name})`);
234
+ }
235
+ if (entry.kind !== "secret" && entry.kind !== "var") {
236
+ throw new Error(`${where}.kind must be "secret" or "var" (key ${name})`);
237
+ }
238
+
239
+ const pair = `${entry.scope}/${entry.kind}`;
240
+ if (seenPairs.has(pair)) {
241
+ throw new Error(
242
+ `${where} repeats the (scope, kind) pair "${pair}" — declare one entry per pair (key ${name})`
243
+ );
244
+ }
245
+ seenPairs.add(pair);
246
+
247
+ if (entry.environments !== undefined) {
248
+ if (entry.scope === "repository") {
249
+ throw new Error(
250
+ `${where}.environments is meaningful only under scope "environment" — a repository-scope ` +
251
+ `secret or variable belongs to no environment (key ${name})`
252
+ );
253
+ }
254
+ if (!Array.isArray(entry.environments) || !entry.environments.every((e) => typeof e === "string")) {
255
+ throw new Error(`${where}.environments must be an array of environment slugs (key ${name})`);
256
+ }
257
+ for (const e of entry.environments) {
258
+ if (!environments.includes(e)) {
259
+ throw new Error(
260
+ `${where}.environments names "${e}", absent from manifest.environments (key ${name})`
261
+ );
262
+ }
263
+ }
264
+ }
265
+
266
+ return {
267
+ scope: entry.scope,
268
+ kind: entry.kind,
269
+ environments: entry.environments ? [...entry.environments] : [...environments],
270
+ };
271
+ });
272
+ }
273
+
184
274
  /**
185
275
  * @param {unknown} entry
186
276
  * @param {number} index
@@ -217,15 +307,7 @@ function validateKeyEntry(entry, index, workers, environments, seen) {
217
307
  throw new Error(`${at}.residency.local must be "var", "secret", "file" or null (key ${name})`);
218
308
  }
219
309
 
220
- const github = residency.github ?? null;
221
- if (github !== null) {
222
- if (github.scope !== "environment" && github.scope !== "repository") {
223
- throw new Error(`${at}.residency.github.scope must be "environment" or "repository" (key ${name})`);
224
- }
225
- if (github.kind !== "secret" && github.kind !== "var") {
226
- throw new Error(`${at}.residency.github.kind must be "secret" or "var" (key ${name})`);
227
- }
228
- }
310
+ const github = normalizeGitHubResidency(residency.github, { at, name, environments });
229
311
 
230
312
  const cloudflare = residency.cloudflare ?? null;
231
313
  if (cloudflare !== null) {
@@ -774,12 +856,22 @@ export function runOfflineChecks({ manifest, repoRoot }) {
774
856
  * @param {string} opts.surface
775
857
  * @param {string | null} opts.environment
776
858
  * @param {string} [opts.scope] Free-text scope for the detail line.
859
+ * @param {Iterable<string> | null} [opts.declaredElsewhere]
860
+ * Names the caller has already accounted for in a SIBLING partition of the
861
+ * same surface. A name in this set is never reported as an orphan here; it
862
+ * has no effect on `missing`. The option exists because a surface can be
863
+ * probed in more than one partition — GitHub is read at repository scope and
864
+ * again per environment — and a key legitimately resident in two of them is
865
+ * not drift. Only the caller knows which partitions are siblings, so the set
866
+ * is supplied rather than inferred: the default is `null`, which keeps this
867
+ * function's behaviour identical for every caller that omits it.
777
868
  * @returns {object[]} findings
778
869
  */
779
- export function reconcileNames({ expected, present, surface, environment, scope = "" }) {
870
+ export function reconcileNames({ expected, present, surface, environment, scope = "", declaredElsewhere = null }) {
780
871
  const findings = [];
781
872
  const presentSet = new Set(present);
782
873
  const expectedSet = new Set(expected);
874
+ const elsewhere = declaredElsewhere ? new Set(declaredElsewhere) : null;
783
875
  const where = scope ? ` (${scope})` : "";
784
876
  for (const name of expected) {
785
877
  if (!presentSet.has(name)) {
@@ -795,6 +887,7 @@ export function reconcileNames({ expected, present, surface, environment, scope
795
887
  }
796
888
  for (const name of present) {
797
889
  if (!expectedSet.has(name)) {
890
+ if (elsewhere?.has(name)) continue;
798
891
  findings.push({
799
892
  severity: "orphan",
800
893
  kind: "orphan",
@@ -1065,6 +1158,45 @@ function unavailableNotice(unavailability, surface) {
1065
1158
  }
1066
1159
 
1067
1160
  /**
1161
+ * Names declared at one GitHub `(scope, kind)`, optionally narrowed to the
1162
+ * entries that name a given environment.
1163
+ *
1164
+ * @param {object[]} keys Parsed manifest keys.
1165
+ * @param {string} scope "repository" | "environment"
1166
+ * @param {string} kind "secret" | "var"
1167
+ * @param {string | null} environment Non-null narrows to entries naming it.
1168
+ * @returns {string[]}
1169
+ */
1170
+ function githubNamesAt(keys, scope, kind, environment) {
1171
+ return keys
1172
+ .filter((k) =>
1173
+ (k.residency.github ?? []).some(
1174
+ (g) =>
1175
+ g.scope === scope && g.kind === kind && (environment === null || g.environments.includes(environment))
1176
+ )
1177
+ )
1178
+ .map((k) => k.name);
1179
+ }
1180
+
1181
+ /**
1182
+ * Reconcile the GitHub surface per `(scope, kind, environment)` triple.
1183
+ *
1184
+ * Two partitions are read — repository scope once, environment scope once per
1185
+ * environment — and a key may legitimately be resident in both. So each
1186
+ * partition suppresses orphans for names declared at the OTHER scope with the
1187
+ * SAME kind, and nothing wider:
1188
+ *
1189
+ * - **Cross-scope, same kind** is suppressed: that is the dual-scope residency
1190
+ * the array form exists to describe, and reporting it was the false orphan.
1191
+ * - **Cross-kind is NOT suppressed.** A name declared as a secret but present
1192
+ * as a variable is a real mismatch, and the orphan is how it surfaces.
1193
+ * - **Cross-environment is NOT suppressed.** A production-only key turning up
1194
+ * in staging is undeclared presence in that environment — arguably the most
1195
+ * interesting thing this surface can find — so it still orphans.
1196
+ *
1197
+ * A name declared in no GitHub scope at all orphans exactly as before, which
1198
+ * is what keeps `--strict-orphans` worth enabling once a manifest is correct.
1199
+ *
1068
1200
  * @param {object} ctx
1069
1201
  */
1070
1202
  async function probeGitHub({ manifest, environments, github, surfaces, findings, unavailability = {} }) {
@@ -1076,18 +1208,18 @@ async function probeGitHub({ manifest, environments, github, surfaces, findings,
1076
1208
  });
1077
1209
  return;
1078
1210
  }
1079
- const repoKeys = manifest.keys.filter((k) => k.residency.github?.scope === "repository");
1080
- const envKeys = manifest.keys.filter((k) => k.residency.github?.scope === "environment");
1211
+ const keys = manifest.keys;
1081
1212
  try {
1082
1213
  const present = await github.repositoryNames();
1083
1214
  for (const kind of ["secret", "var"]) {
1084
1215
  findings.push(
1085
1216
  ...reconcileNames({
1086
- expected: repoKeys.filter((k) => k.residency.github.kind === kind).map((k) => k.name),
1217
+ expected: githubNamesAt(keys, "repository", kind, null),
1087
1218
  present: present[kind] ?? [],
1088
1219
  surface: "github",
1089
1220
  environment: null,
1090
1221
  scope: `repository ${kind}s`,
1222
+ declaredElsewhere: githubNamesAt(keys, "environment", kind, null),
1091
1223
  })
1092
1224
  );
1093
1225
  }
@@ -1096,11 +1228,12 @@ async function probeGitHub({ manifest, environments, github, surfaces, findings,
1096
1228
  for (const kind of ["secret", "var"]) {
1097
1229
  findings.push(
1098
1230
  ...reconcileNames({
1099
- expected: envKeys.filter((k) => k.residency.github.kind === kind).map((k) => k.name),
1231
+ expected: githubNamesAt(keys, "environment", kind, environment),
1100
1232
  present: envPresent[kind] ?? [],
1101
1233
  surface: "github",
1102
1234
  environment,
1103
1235
  scope: `environment ${kind}s`,
1236
+ declaredElsewhere: githubNamesAt(keys, "repository", kind, null),
1104
1237
  })
1105
1238
  );
1106
1239
  }
@@ -186,6 +186,78 @@ test("parseManifest rejects a duplicate key name and an infisical env outside th
186
186
  assert.throws(() => parseManifest(badEnv), /absent from manifest.environments/);
187
187
  });
188
188
 
189
+ test("parseManifest normalizes the single-object residency.github to a one-entry array", () => {
190
+ const m = parseManifest(singleWorkerManifest());
191
+ // The authored shape is an object; the parsed shape is always an array, so
192
+ // probeGitHub reads exactly one shape. `environments` defaults to all of
193
+ // manifest.environments, the same treatment infisical.environments gets.
194
+ assert.deepEqual(m.keys[0].residency.github, [
195
+ { scope: "environment", kind: "var", environments: ["staging", "production"] },
196
+ ]);
197
+ });
198
+
199
+ test("parseManifest accepts the array form and defaults environments per entry", () => {
200
+ const raw = singleWorkerManifest();
201
+ raw.keys[0].residency.github = [
202
+ { scope: "repository", kind: "var" },
203
+ { scope: "environment", kind: "var", environments: ["production"] },
204
+ ];
205
+ const m = parseManifest(raw);
206
+ assert.deepEqual(m.keys[0].residency.github, [
207
+ { scope: "repository", kind: "var", environments: ["staging", "production"] },
208
+ { scope: "environment", kind: "var", environments: ["production"] },
209
+ ]);
210
+ });
211
+
212
+ test("parseManifest rejects an residency.github environments slug outside manifest.environments", () => {
213
+ const raw = singleWorkerManifest();
214
+ raw.keys[0].residency.github = [{ scope: "environment", kind: "var", environments: ["preview"] }];
215
+ assert.throws(() => parseManifest(raw), (err) => {
216
+ assert.match(err.message, /absent from manifest.environments/);
217
+ assert.match(err.message, /PUBLIC_SITE_URL/);
218
+ return true;
219
+ });
220
+ });
221
+
222
+ test("parseManifest rejects environments on a repository-scope entry rather than ignoring it", () => {
223
+ // Silently ignoring it is how an author comes to believe they scoped
224
+ // something they did not — the same fail-closed posture as an unknown shape.
225
+ const raw = singleWorkerManifest();
226
+ raw.keys[0].residency.github = [{ scope: "repository", kind: "var", environments: ["production"] }];
227
+ assert.throws(() => parseManifest(raw), (err) => {
228
+ assert.match(err.message, /meaningful only under scope "environment"/);
229
+ assert.match(err.message, /PUBLIC_SITE_URL/);
230
+ return true;
231
+ });
232
+ });
233
+
234
+ test("parseManifest rejects a duplicate (scope, kind) pair and an empty residency.github array", () => {
235
+ const dup = singleWorkerManifest();
236
+ dup.keys[0].residency.github = [
237
+ { scope: "environment", kind: "var" },
238
+ { scope: "environment", kind: "var", environments: ["production"] },
239
+ ];
240
+ assert.throws(() => parseManifest(dup), (err) => {
241
+ assert.match(err.message, /repeats the \(scope, kind\) pair "environment\/var"/);
242
+ assert.match(err.message, /PUBLIC_SITE_URL/);
243
+ return true;
244
+ });
245
+
246
+ const empty = singleWorkerManifest();
247
+ empty.keys[0].residency.github = [];
248
+ assert.throws(() => parseManifest(empty), /must not be an empty array/);
249
+ });
250
+
251
+ test("parseManifest still rejects a malformed scope and kind under both authored shapes", () => {
252
+ const objForm = singleWorkerManifest();
253
+ objForm.keys[0].residency.github = { scope: "org", kind: "var" };
254
+ assert.throws(() => parseManifest(objForm), /\.scope must be "environment" or "repository"/);
255
+
256
+ const arrForm = singleWorkerManifest();
257
+ arrForm.keys[0].residency.github = [{ scope: "repository", kind: "file" }];
258
+ assert.throws(() => parseManifest(arrForm), /residency\.github\[0\]\.kind must be "secret" or "var"/);
259
+ });
260
+
189
261
  test("resolveScriptName substitutes {env} per environment", () => {
190
262
  assert.equal(resolveScriptName("acme-site-{env}", "staging"), "acme-site-staging");
191
263
  assert.equal(resolveScriptName("acme-site-{env}", "production"), "acme-site-production");
@@ -458,6 +530,214 @@ test("a non-404 probe failure becomes an error surface and exits 1 — never 'no
458
530
  }
459
531
  });
460
532
 
533
+ // ---------------------------------------------------------------------------
534
+ // GitHub residency — dual scope and per-environment presence (Story #459)
535
+ // ---------------------------------------------------------------------------
536
+
537
+ /**
538
+ * A manifest with no workers and no local residency, so the offline arm over an
539
+ * empty repo root contributes no findings and every finding under test comes
540
+ * from the GitHub probe.
541
+ */
542
+ function githubOnlyManifest(github) {
543
+ return parseManifest({
544
+ environments: ["staging", "production"],
545
+ keys: [{ name: "SHARED_TOKEN", kind: "secret", sensitivity: "secret", residency: { local: null, github } }],
546
+ });
547
+ }
548
+
549
+ /** Mock the two GitHub probe calls from a `{repository, staging, production}` map. */
550
+ function githubProbe(present) {
551
+ const at = (slot) => ({ secret: [], var: [], ...(present[slot] ?? {}) });
552
+ return {
553
+ repositoryNames: async () => at("repository"),
554
+ environmentNames: async (environment) => at(environment),
555
+ };
556
+ }
557
+
558
+ async function githubFindings({ manifest, present, environments = ["staging", "production"] }) {
559
+ const root = makeRepo({});
560
+ try {
561
+ const report = await runDoctor({ manifest, repoRoot: root, environments, github: githubProbe(present) });
562
+ return report.findings.filter((f) => f.surface === "github");
563
+ } finally {
564
+ rmSync(root, { recursive: true, force: true });
565
+ }
566
+ }
567
+
568
+ const DUAL_SCOPE = [
569
+ { scope: "repository", kind: "secret" },
570
+ { scope: "environment", kind: "secret" },
571
+ ];
572
+
573
+ test("a dual-scope key present at both scopes reports no finding", async () => {
574
+ const findings = await githubFindings({
575
+ manifest: githubOnlyManifest(DUAL_SCOPE),
576
+ present: {
577
+ repository: { secret: ["SHARED_TOKEN"] },
578
+ staging: { secret: ["SHARED_TOKEN"] },
579
+ production: { secret: ["SHARED_TOKEN"] },
580
+ },
581
+ });
582
+ assert.deepEqual(findings, []);
583
+ });
584
+
585
+ test("a dual-scope key absent from one scope reports a missing naming that scope only", async () => {
586
+ const findings = await githubFindings({
587
+ manifest: githubOnlyManifest(DUAL_SCOPE),
588
+ present: {
589
+ repository: { secret: [] },
590
+ staging: { secret: ["SHARED_TOKEN"] },
591
+ production: { secret: ["SHARED_TOKEN"] },
592
+ },
593
+ });
594
+ assert.equal(findings.length, 1);
595
+ assert.equal(findings[0].kind, "missing");
596
+ assert.equal(findings[0].key, "SHARED_TOKEN");
597
+ assert.equal(findings[0].environment, null);
598
+ assert.match(findings[0].detail, /repository secrets/);
599
+ });
600
+
601
+ test("a dual-scope key absent from one environment reports a missing naming that environment only", async () => {
602
+ const findings = await githubFindings({
603
+ manifest: githubOnlyManifest(DUAL_SCOPE),
604
+ present: {
605
+ repository: { secret: ["SHARED_TOKEN"] },
606
+ staging: { secret: [] },
607
+ production: { secret: ["SHARED_TOKEN"] },
608
+ },
609
+ });
610
+ assert.equal(findings.length, 1);
611
+ assert.equal(findings[0].kind, "missing");
612
+ assert.equal(findings[0].environment, "staging");
613
+ });
614
+
615
+ test("an environments-scoped entry reports no missing for an environment it never names", async () => {
616
+ // The motivating case: a production-only analytics token whose staging
617
+ // counterpart is meant to resolve empty. That is design, not drift.
618
+ const manifest = githubOnlyManifest([{ scope: "environment", kind: "secret", environments: ["production"] }]);
619
+ assert.deepEqual(
620
+ await githubFindings({ manifest, present: { production: { secret: ["SHARED_TOKEN"] } } }),
621
+ []
622
+ );
623
+
624
+ const missing = await githubFindings({ manifest, present: {} });
625
+ assert.equal(missing.length, 1, "production still fails when it lacks the key");
626
+ assert.equal(missing[0].kind, "missing");
627
+ assert.equal(missing[0].environment, "production");
628
+ });
629
+
630
+ test("a key declared only at environment scope is not a repository-level orphan", async () => {
631
+ const findings = await githubFindings({
632
+ manifest: githubOnlyManifest([{ scope: "environment", kind: "secret" }]),
633
+ present: {
634
+ repository: { secret: ["SHARED_TOKEN"] },
635
+ staging: { secret: ["SHARED_TOKEN"] },
636
+ production: { secret: ["SHARED_TOKEN"] },
637
+ },
638
+ });
639
+ assert.deepEqual(findings, []);
640
+ });
641
+
642
+ test("suppression is cross-scope only — an undeclared name still orphans", async () => {
643
+ // The half that keeps --strict-orphans worth enabling: nothing about the
644
+ // suppression hides a name the manifest never declared anywhere.
645
+ const findings = await githubFindings({
646
+ manifest: githubOnlyManifest([{ scope: "environment", kind: "secret" }]),
647
+ present: {
648
+ repository: { secret: ["SHARED_TOKEN", "UNDECLARED_TOKEN"] },
649
+ staging: { secret: ["SHARED_TOKEN"] },
650
+ production: { secret: ["SHARED_TOKEN"] },
651
+ },
652
+ });
653
+ assert.equal(findings.length, 1);
654
+ assert.equal(findings[0].kind, "orphan");
655
+ assert.equal(findings[0].key, "UNDECLARED_TOKEN");
656
+ assert.equal(findings[0].environment, null);
657
+ });
658
+
659
+ test("suppression does not cross kind — a secret declared, a variable present, still orphans", async () => {
660
+ const findings = await githubFindings({
661
+ manifest: githubOnlyManifest([{ scope: "environment", kind: "secret" }]),
662
+ present: {
663
+ repository: { var: ["SHARED_TOKEN"] },
664
+ staging: { secret: ["SHARED_TOKEN"] },
665
+ production: { secret: ["SHARED_TOKEN"] },
666
+ },
667
+ });
668
+ assert.equal(findings.length, 1);
669
+ assert.equal(findings[0].kind, "orphan");
670
+ assert.match(findings[0].detail, /repository vars/);
671
+ });
672
+
673
+ test("suppression does not cross environment — a production-only key found in staging orphans", async () => {
674
+ // Undeclared presence in the wrong environment is the most interesting thing
675
+ // this surface can find, so the cross-scope rule must not reach it.
676
+ const findings = await githubFindings({
677
+ manifest: githubOnlyManifest([{ scope: "environment", kind: "secret", environments: ["production"] }]),
678
+ present: {
679
+ staging: { secret: ["SHARED_TOKEN"] },
680
+ production: { secret: ["SHARED_TOKEN"] },
681
+ },
682
+ });
683
+ assert.equal(findings.length, 1);
684
+ assert.equal(findings[0].kind, "orphan");
685
+ assert.equal(findings[0].environment, "staging");
686
+ });
687
+
688
+ test("the single-object residency.github form yields exactly the findings it did before", async () => {
689
+ // The regression case, over the unchanged existing fixture: both keys are
690
+ // authored as single objects at environment scope, so a repo-level probe
691
+ // holding neither must produce two per-environment missings and nothing else.
692
+ const root = makeRepo(CONSISTENT_REPO);
693
+ try {
694
+ const report = await runDoctor({
695
+ manifest: parseManifest(singleWorkerManifest()),
696
+ repoRoot: root,
697
+ environments: ["staging", "production"],
698
+ github: githubProbe({
699
+ staging: { var: ["PUBLIC_SITE_URL"], secret: ["TURSO_AUTH_TOKEN"] },
700
+ production: { var: ["PUBLIC_SITE_URL"] },
701
+ }),
702
+ });
703
+ const gh = report.findings.filter((f) => f.surface === "github");
704
+ assert.equal(gh.length, 1);
705
+ assert.deepEqual(
706
+ { kind: gh[0].kind, key: gh[0].key, environment: gh[0].environment },
707
+ { kind: "missing", key: "TURSO_AUTH_TOKEN", environment: "production" }
708
+ );
709
+ assert.equal(report.surfaces.find((s) => s.surface === "github").status, "checked");
710
+ } finally {
711
+ rmSync(root, { recursive: true, force: true });
712
+ }
713
+ });
714
+
715
+ test("declaredElsewhere suppresses only orphans, never a missing", async () => {
716
+ // The new argument is opt-in and orphan-only, so the cloudflare and infisical
717
+ // call sites that omit it cannot change behaviour.
718
+ const suppressed = reconcileNames({
719
+ expected: ["A"],
720
+ present: ["B"],
721
+ surface: "github",
722
+ environment: null,
723
+ declaredElsewhere: ["A", "B"],
724
+ });
725
+ assert.deepEqual(
726
+ suppressed.map((f) => [f.kind, f.key]),
727
+ [["missing", "A"]]
728
+ );
729
+ assert.deepEqual(reconcileNames({ expected: [], present: ["B"], surface: "github", environment: null }), [
730
+ {
731
+ severity: "orphan",
732
+ kind: "orphan",
733
+ key: "B",
734
+ surface: "github",
735
+ environment: null,
736
+ detail: "present in github but declared by no manifest key",
737
+ },
738
+ ]);
739
+ });
740
+
461
741
  test("the Cloudflare probe resolves {env} in scriptName once per environment", async () => {
462
742
  const root = makeRepo(CONSISTENT_REPO);
463
743
  const asked = [];