mandrel-platform 0.17.2 → 0.18.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.
@@ -17,8 +17,20 @@
17
17
  * node scripts/check-required-contexts.mjs --contract path/to/main-protection.json
18
18
  * node scripts/check-required-contexts.mjs --workflows-dir .github/workflows
19
19
  *
20
+ * Also warns (never blocks) when the caller-naming triplet — the CI workflow
21
+ * file name, its display `name:`, and the caller job id that wraps the
22
+ * `pr-quality.yml` call — diverges from the canonical shape documented in
23
+ * `docs/reusable-workflows.md` (`ci.yml` / `CI` / caller job id `ci`, giving
24
+ * the required context `ci / ci-required`). Three fleet consumers converged
25
+ * on three different spellings for the same shared workflow (validated
26
+ * 2026-07-01: domio `ci-pr.yml` / `CI (PR)`; athportal `quality.yml` /
27
+ * `quality`; swarm-os `ci.yml` / `CI` with job id `quality`) — this is a
28
+ * lint nudge toward convergence, not a gate, so existing non-canonical
29
+ * consumers are never blocked by adopting this script.
30
+ *
20
31
  * Exit codes:
21
32
  * 0 — all required contexts are emitted by at least one workflow job
33
+ * (regardless of caller-naming warnings — those never affect the exit code)
22
34
  * 1 — one or more phantom contexts detected (named in stderr)
23
35
  *
24
36
  * Consumer adoption:
@@ -36,63 +48,11 @@ import { readFileSync, readdirSync } from "node:fs";
36
48
  import { resolve, join, relative } from "node:path";
37
49
 
38
50
  // ---------------------------------------------------------------------------
39
- // Arg parsing
51
+ // Repo-root resolution (used by both the CLI entrypoint below and the
52
+ // exported naming-lint helper, which reports paths relative to it)
40
53
  // ---------------------------------------------------------------------------
41
54
 
42
- const args = process.argv.slice(2);
43
- let contractPath = null;
44
- let workflowsDir = null;
45
-
46
- for (let i = 0; i < args.length; i++) {
47
- if ((args[i] === "--contract" || args[i] === "-c") && args[i + 1]) {
48
- contractPath = args[++i];
49
- } else if ((args[i] === "--workflows-dir" || args[i] === "-w") && args[i + 1]) {
50
- workflowsDir = args[++i];
51
- } else if (args[i] === "--help" || args[i] === "-h") {
52
- process.stdout.write(
53
- "Usage: node scripts/check-required-contexts.mjs [--contract <path>] [--workflows-dir <dir>]\n"
54
- );
55
- process.exit(0);
56
- }
57
- }
58
-
59
- // Resolve paths relative to the repo root (cwd when invoked from CI or locally).
60
55
  const repoRoot = process.cwd();
61
- const resolvedContract = contractPath
62
- ? resolve(contractPath)
63
- : resolve(repoRoot, "docs/runbooks/main-protection.json");
64
- const resolvedWorkflowsDir = workflowsDir
65
- ? resolve(workflowsDir)
66
- : resolve(repoRoot, ".github/workflows");
67
-
68
- // ---------------------------------------------------------------------------
69
- // Load contract
70
- // ---------------------------------------------------------------------------
71
-
72
- let contract;
73
- try {
74
- const raw = readFileSync(resolvedContract, "utf8");
75
- contract = JSON.parse(raw);
76
- } catch (err) {
77
- process.stderr.write(
78
- `[check-required-contexts] ERROR: Cannot read contract at ${relative(repoRoot, resolvedContract)}: ${err.message}\n`
79
- );
80
- process.exit(1);
81
- }
82
-
83
- const requiredContexts = Array.isArray(contract.requiredStatusChecks)
84
- ? contract.requiredStatusChecks
85
- : [];
86
- const upstreamJobs = Array.isArray(contract.upstreamJobs)
87
- ? contract.upstreamJobs
88
- : [];
89
-
90
- if (requiredContexts.length === 0) {
91
- process.stderr.write(
92
- "[check-required-contexts] ERROR: contract.requiredStatusChecks is empty — at least one context is required.\n"
93
- );
94
- process.exit(1);
95
- }
96
56
 
97
57
  // ---------------------------------------------------------------------------
98
58
  // Collect job names from all workflow files
@@ -114,7 +74,7 @@ if (requiredContexts.length === 0) {
114
74
  *
115
75
  * The job ID line is indented by exactly 2 spaces and ends with a colon.
116
76
  */
117
- function extractJobIds(yamlContent) {
77
+ export function extractJobIds(yamlContent) {
118
78
  const ids = new Set();
119
79
  let inJobsBlock = false;
120
80
 
@@ -143,105 +103,263 @@ function extractJobIds(yamlContent) {
143
103
  return ids;
144
104
  }
145
105
 
146
- let workflowFiles;
147
- try {
148
- workflowFiles = readdirSync(resolvedWorkflowsDir).filter(
149
- (f) => f.endsWith(".yml") || f.endsWith(".yaml")
150
- );
151
- } catch (err) {
152
- process.stderr.write(
153
- `[check-required-contexts] ERROR: Cannot read workflows directory at ${relative(repoRoot, resolvedWorkflowsDir)}: ${err.message}\n`
154
- );
155
- process.exit(1);
106
+ /**
107
+ * Extract the workflow's top-level display `name:` (the value GitHub Actions
108
+ * shows in the UI and uses as the first segment of a workflow_call caller's
109
+ * status context, e.g. "CI / ci-required"). Returns null when no top-level
110
+ * `name:` key is present (GitHub then falls back to the file path).
111
+ *
112
+ * Only the zero-indentation `name:` key qualifies — job-level `name:` keys
113
+ * are indented and must not be mistaken for the workflow's own display name.
114
+ */
115
+ export function extractWorkflowName(yamlContent) {
116
+ for (const line of yamlContent.split("\n")) {
117
+ const match = line.match(/^name:\s*(.+?)\s*$/);
118
+ if (match) {
119
+ return match[1].replace(/^["']|["']$/g, "");
120
+ }
121
+ // Stop scanning once we reach `on:` or `jobs:` — `name:` is always a
122
+ // top-of-file key in a well-formed workflow, so nothing past those
123
+ // markers should be mistaken for it.
124
+ if (/^(on|jobs):\s*$/.test(line)) break;
125
+ }
126
+ return null;
156
127
  }
157
128
 
158
- if (workflowFiles.length === 0) {
159
- process.stderr.write(
160
- `[check-required-contexts] ERROR: No workflow files found in ${relative(repoRoot, resolvedWorkflowsDir)}\n`
161
- );
162
- process.exit(1);
163
- }
129
+ // ---------------------------------------------------------------------------
130
+ // Canonical caller-naming lint (warn-only — see docs/reusable-workflows.md
131
+ // "Canonical caller naming" for the full rationale and target shape).
132
+ // ---------------------------------------------------------------------------
164
133
 
165
- /** Map from workflow filename → Set<jobId> */
166
- const workflowJobMap = new Map();
167
- /** Flat set of all job IDs across all workflows */
168
- const allJobIds = new Set();
134
+ export const CANONICAL_CALLER = {
135
+ file: "ci.yml",
136
+ displayName: "CI",
137
+ jobId: "ci",
138
+ };
169
139
 
170
- for (const file of workflowFiles) {
171
- const filePath = join(resolvedWorkflowsDir, file);
140
+ /**
141
+ * Non-blocking nudge toward the canonical `ci.yml` / `CI` / `ci` caller
142
+ * naming triplet (operator decision 2026-07-01, D2). Never affects the exit
143
+ * code — this is drift *signal*, not a gate, so existing consumers already on
144
+ * a non-canonical spelling (domio's `ci-pr.yml`, athportal's `quality.yml`,
145
+ * swarm-os's `quality` job id) are never blocked by adopting this script.
146
+ */
147
+ export function warnOnNonCanonicalCallerNaming(workflowFiles, workflowJobMap, resolvedWorkflowsDir) {
148
+ const warnings = [];
149
+
150
+ if (!workflowFiles.includes(CANONICAL_CALLER.file)) {
151
+ warnings.push(
152
+ `no "${CANONICAL_CALLER.file}" file found in ${relative(repoRoot, resolvedWorkflowsDir)}/ — ` +
153
+ `the canonical caller file name is "${CANONICAL_CALLER.file}" (found: ${workflowFiles.join(", ")})`
154
+ );
155
+ return warnings;
156
+ }
157
+
158
+ const filePath = join(resolvedWorkflowsDir, CANONICAL_CALLER.file);
172
159
  let content;
173
160
  try {
174
161
  content = readFileSync(filePath, "utf8");
175
- } catch (err) {
176
- process.stderr.write(
177
- `[check-required-contexts] WARN: Cannot read ${file}: ${err.message} — skipping.\n`
162
+ } catch {
163
+ return warnings;
164
+ }
165
+
166
+ const displayName = extractWorkflowName(content);
167
+ if (displayName !== CANONICAL_CALLER.displayName) {
168
+ warnings.push(
169
+ `${CANONICAL_CALLER.file} display name is "${displayName ?? "(none)"}" — ` +
170
+ `canonical is "${CANONICAL_CALLER.displayName}"`
178
171
  );
179
- continue;
180
172
  }
181
- const ids = extractJobIds(content);
182
- workflowJobMap.set(file, ids);
183
- for (const id of ids) {
184
- allJobIds.add(id);
173
+
174
+ const jobIds = workflowJobMap.get(CANONICAL_CALLER.file) ?? new Set();
175
+ if (!jobIds.has(CANONICAL_CALLER.jobId)) {
176
+ warnings.push(
177
+ `${CANONICAL_CALLER.file} has no "${CANONICAL_CALLER.jobId}" job id — ` +
178
+ `canonical required context is "${CANONICAL_CALLER.jobId} / ci-required" ` +
179
+ `(found job ids: ${[...jobIds].sort().join(", ") || "(none)"})`
180
+ );
185
181
  }
182
+
183
+ return warnings;
186
184
  }
187
185
 
188
186
  // ---------------------------------------------------------------------------
189
- // Validate required contexts
187
+ // CLI entrypoint (skipped under `node --test` import — see the guard below)
190
188
  // ---------------------------------------------------------------------------
191
189
 
192
- const phantomContexts = requiredContexts.filter((ctx) => !allJobIds.has(ctx));
193
- const phantomUpstream = upstreamJobs.filter((job) => !allJobIds.has(job));
190
+ export function runCli(argv) {
191
+ // ── Arg parsing ────────────────────────────────────────────────────────
192
+ let contractPath = null;
193
+ let workflowsDir = null;
194
194
 
195
- // ---------------------------------------------------------------------------
196
- // Report
197
- // ---------------------------------------------------------------------------
195
+ for (let i = 0; i < argv.length; i++) {
196
+ if ((argv[i] === "--contract" || argv[i] === "-c") && argv[i + 1]) {
197
+ contractPath = argv[++i];
198
+ } else if ((argv[i] === "--workflows-dir" || argv[i] === "-w") && argv[i + 1]) {
199
+ workflowsDir = argv[++i];
200
+ } else if (argv[i] === "--help" || argv[i] === "-h") {
201
+ process.stdout.write(
202
+ "Usage: node scripts/check-required-contexts.mjs [--contract <path>] [--workflows-dir <dir>]\n"
203
+ );
204
+ return 0;
205
+ }
206
+ }
198
207
 
199
- const contractRel = relative(repoRoot, resolvedContract);
200
- const workflowsRel = relative(repoRoot, resolvedWorkflowsDir);
208
+ // Resolve paths relative to the repo root (cwd when invoked from CI or locally).
209
+ const resolvedContract = contractPath
210
+ ? resolve(contractPath)
211
+ : resolve(repoRoot, "docs/runbooks/main-protection.json");
212
+ const resolvedWorkflowsDir = workflowsDir
213
+ ? resolve(workflowsDir)
214
+ : resolve(repoRoot, ".github/workflows");
201
215
 
202
- process.stdout.write(
203
- `[check-required-contexts] Contract : ${contractRel}\n` +
204
- `[check-required-contexts] Workflows: ${workflowsRel}/ (${workflowFiles.length} file${workflowFiles.length === 1 ? "" : "s"})\n` +
205
- `[check-required-contexts] Emitted job IDs: ${[...allJobIds].sort().join(", ")}\n`
206
- );
216
+ // ── Load contract ──────────────────────────────────────────────────────
217
+ let contract;
218
+ try {
219
+ const raw = readFileSync(resolvedContract, "utf8");
220
+ contract = JSON.parse(raw);
221
+ } catch (err) {
222
+ process.stderr.write(
223
+ `[check-required-contexts] ERROR: Cannot read contract at ${relative(repoRoot, resolvedContract)}: ${err.message}\n`
224
+ );
225
+ return 1;
226
+ }
207
227
 
208
- if (phantomContexts.length > 0) {
209
- process.stderr.write(
210
- `\n[check-required-contexts] ❌ PHANTOM required contexts detected!\n` +
211
- ` These contexts are listed in requiredStatusChecks but no workflow job emits them:\n`
212
- );
213
- for (const ctx of phantomContexts) {
214
- process.stderr.write(` • "${ctx}"\n`);
228
+ const requiredContexts = Array.isArray(contract.requiredStatusChecks)
229
+ ? contract.requiredStatusChecks
230
+ : [];
231
+ const upstreamJobs = Array.isArray(contract.upstreamJobs) ? contract.upstreamJobs : [];
232
+
233
+ if (requiredContexts.length === 0) {
234
+ process.stderr.write(
235
+ "[check-required-contexts] ERROR: contract.requiredStatusChecks is empty — at least one context is required.\n"
236
+ );
237
+ return 1;
215
238
  }
216
- process.stderr.write(
217
- `\n A phantom context will block every PR indefinitely on a "pending" status\n` +
218
- ` that never resolves. Fix: either add a workflow job with this exact ID,\n` +
219
- ` or remove the context from requiredStatusChecks in ${contractRel}.\n\n`
220
- );
221
- }
222
239
 
223
- if (phantomUpstream.length > 0) {
224
- process.stderr.write(
225
- `\n[check-required-contexts] ❌ PHANTOM upstream jobs detected!\n` +
226
- ` These jobs are listed in upstreamJobs but no workflow defines them:\n`
227
- );
228
- for (const job of phantomUpstream) {
229
- process.stderr.write(` • "${job}"\n`);
240
+ // ── Collect job names from all workflow files ─────────────────────────
241
+ let workflowFiles;
242
+ try {
243
+ workflowFiles = readdirSync(resolvedWorkflowsDir).filter(
244
+ (f) => f.endsWith(".yml") || f.endsWith(".yaml")
245
+ );
246
+ } catch (err) {
247
+ process.stderr.write(
248
+ `[check-required-contexts] ERROR: Cannot read workflows directory at ${relative(repoRoot, resolvedWorkflowsDir)}: ${err.message}\n`
249
+ );
250
+ return 1;
230
251
  }
231
- process.stderr.write(
232
- `\n Fix: either add the missing job to a workflow, or remove it from\n` +
233
- ` upstreamJobs in ${contractRel}.\n\n`
234
- );
235
- }
236
252
 
237
- const hasError = phantomContexts.length > 0 || phantomUpstream.length > 0;
253
+ if (workflowFiles.length === 0) {
254
+ process.stderr.write(
255
+ `[check-required-contexts] ERROR: No workflow files found in ${relative(repoRoot, resolvedWorkflowsDir)}\n`
256
+ );
257
+ return 1;
258
+ }
259
+
260
+ /** Map from workflow filename → Set<jobId> */
261
+ const workflowJobMap = new Map();
262
+ /** Flat set of all job IDs across all workflows */
263
+ const allJobIds = new Set();
264
+
265
+ for (const file of workflowFiles) {
266
+ const filePath = join(resolvedWorkflowsDir, file);
267
+ let content;
268
+ try {
269
+ content = readFileSync(filePath, "utf8");
270
+ } catch (err) {
271
+ process.stderr.write(
272
+ `[check-required-contexts] WARN: Cannot read ${file}: ${err.message} — skipping.\n`
273
+ );
274
+ continue;
275
+ }
276
+ const ids = extractJobIds(content);
277
+ workflowJobMap.set(file, ids);
278
+ for (const id of ids) {
279
+ allJobIds.add(id);
280
+ }
281
+ }
282
+
283
+ // ── Validate required contexts ─────────────────────────────────────────
284
+ const phantomContexts = requiredContexts.filter((ctx) => !allJobIds.has(ctx));
285
+ const phantomUpstream = upstreamJobs.filter((job) => !allJobIds.has(job));
286
+
287
+ // ── Report ──────────────────────────────────────────────────────────────
288
+ const contractRel = relative(repoRoot, resolvedContract);
289
+ const workflowsRel = relative(repoRoot, resolvedWorkflowsDir);
238
290
 
239
- if (!hasError) {
240
291
  process.stdout.write(
241
- `[check-required-contexts] All required contexts and upstream jobs are emitted by CI.\n` +
242
- ` requiredStatusChecks : ${requiredContexts.join(", ")}\n` +
243
- ` upstreamJobs : ${upstreamJobs.length > 0 ? upstreamJobs.join(", ") : "(none listed)"}\n`
292
+ `[check-required-contexts] Contract : ${contractRel}\n` +
293
+ `[check-required-contexts] Workflows: ${workflowsRel}/ (${workflowFiles.length} file${workflowFiles.length === 1 ? "" : "s"})\n` +
294
+ `[check-required-contexts] Emitted job IDs: ${[...allJobIds].sort().join(", ")}\n`
244
295
  );
296
+
297
+ if (phantomContexts.length > 0) {
298
+ process.stderr.write(
299
+ `\n[check-required-contexts] ❌ PHANTOM required contexts detected!\n` +
300
+ ` These contexts are listed in requiredStatusChecks but no workflow job emits them:\n`
301
+ );
302
+ for (const ctx of phantomContexts) {
303
+ process.stderr.write(` • "${ctx}"\n`);
304
+ }
305
+ process.stderr.write(
306
+ `\n A phantom context will block every PR indefinitely on a "pending" status\n` +
307
+ ` that never resolves. Fix: either add a workflow job with this exact ID,\n` +
308
+ ` or remove the context from requiredStatusChecks in ${contractRel}.\n\n`
309
+ );
310
+ }
311
+
312
+ if (phantomUpstream.length > 0) {
313
+ process.stderr.write(
314
+ `\n[check-required-contexts] ❌ PHANTOM upstream jobs detected!\n` +
315
+ ` These jobs are listed in upstreamJobs but no workflow defines them:\n`
316
+ );
317
+ for (const job of phantomUpstream) {
318
+ process.stderr.write(` • "${job}"\n`);
319
+ }
320
+ process.stderr.write(
321
+ `\n Fix: either add the missing job to a workflow, or remove it from\n` +
322
+ ` upstreamJobs in ${contractRel}.\n\n`
323
+ );
324
+ }
325
+
326
+ const namingWarnings = warnOnNonCanonicalCallerNaming(
327
+ workflowFiles,
328
+ workflowJobMap,
329
+ resolvedWorkflowsDir
330
+ );
331
+
332
+ if (namingWarnings.length > 0) {
333
+ process.stdout.write(
334
+ `\n[check-required-contexts] ⚠️ Non-canonical CI caller naming (warn-only, does not fail this check):\n`
335
+ );
336
+ for (const warning of namingWarnings) {
337
+ process.stdout.write(` • ${warning}\n`);
338
+ }
339
+ process.stdout.write(
340
+ `\n See docs/reusable-workflows.md § "Canonical caller naming" for the\n` +
341
+ ` target shape (file "${CANONICAL_CALLER.file}", display name "${CANONICAL_CALLER.displayName}",\n` +
342
+ ` caller job id "${CANONICAL_CALLER.jobId}" → required context "${CANONICAL_CALLER.jobId} / ci-required").\n` +
343
+ ` Renaming an existing caller is a per-consumer migration, not required by this lint.\n\n`
344
+ );
345
+ }
346
+
347
+ const hasError = phantomContexts.length > 0 || phantomUpstream.length > 0;
348
+
349
+ if (!hasError) {
350
+ process.stdout.write(
351
+ `[check-required-contexts] ✅ All required contexts and upstream jobs are emitted by CI.\n` +
352
+ ` requiredStatusChecks : ${requiredContexts.join(", ")}\n` +
353
+ ` upstreamJobs : ${upstreamJobs.length > 0 ? upstreamJobs.join(", ") : "(none listed)"}\n`
354
+ );
355
+ }
356
+
357
+ return hasError ? 1 : 0;
245
358
  }
246
359
 
247
- process.exit(hasError ? 1 : 0);
360
+ // Only run when executed directly, not when imported by the test suite.
361
+ const invokedDirectly =
362
+ process.argv[1] && resolve(process.argv[1]).endsWith("check-required-contexts.mjs");
363
+ if (invokedDirectly) {
364
+ process.exit(runCli(process.argv.slice(2)));
365
+ }
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-required-contexts.test.mjs — node:test suite for the canonical
4
+ * CI-caller-naming lint added to check-required-contexts.mjs (Story #173).
5
+ *
6
+ * The pre-existing phantom-context checker is exercised end-to-end via the
7
+ * CLI (see docs/reusable-workflows.md § "Canonical caller naming" for the
8
+ * target shape this lint nudges consumers toward). This suite covers the
9
+ * three pure functions the naming lint adds: `extractWorkflowName`,
10
+ * `extractJobIds` (pre-existing, re-exercised for the new call sites), and
11
+ * `warnOnNonCanonicalCallerNaming` — all offline, no filesystem beyond a
12
+ * scratch temp dir for the workflows-dir fixture.
13
+ *
14
+ * Run: node scripts/check-required-contexts.test.mjs (or `node --test scripts/`)
15
+ */
16
+
17
+ import assert from "node:assert/strict";
18
+ import { test } from "node:test";
19
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
20
+ import { tmpdir } from "node:os";
21
+ import { join } from "node:path";
22
+
23
+ import {
24
+ CANONICAL_CALLER,
25
+ extractJobIds,
26
+ extractWorkflowName,
27
+ warnOnNonCanonicalCallerNaming,
28
+ } from "./check-required-contexts.mjs";
29
+
30
+ // ── extractWorkflowName ─────────────────────────────────────────────────────
31
+
32
+ test("extractWorkflowName: reads the top-level `name:` key", () => {
33
+ const yaml = `name: CI\n\non:\n pull_request:\n branches: [main]\n\njobs:\n ci:\n runs-on: ubuntu-latest\n`;
34
+ assert.equal(extractWorkflowName(yaml), "CI");
35
+ });
36
+
37
+ test("extractWorkflowName: strips surrounding quotes", () => {
38
+ assert.equal(extractWorkflowName('name: "CI (PR)"\non:\n'), "CI (PR)");
39
+ assert.equal(extractWorkflowName("name: 'quality'\non:\n"), "quality");
40
+ });
41
+
42
+ test("extractWorkflowName: returns null when no top-level name: is present", () => {
43
+ const yaml = `on:\n push:\n branches: [main]\njobs:\n build:\n name: Build\n`;
44
+ assert.equal(extractWorkflowName(yaml), null);
45
+ });
46
+
47
+ test("extractWorkflowName: does not mistake a job-level name: for the workflow name", () => {
48
+ const yaml = `on:\n push:\njobs:\n build:\n name: Build job display name\n`;
49
+ assert.equal(extractWorkflowName(yaml), null);
50
+ });
51
+
52
+ // ── extractJobIds (pre-existing behaviour, re-exercised for the new caller) ─
53
+
54
+ test("extractJobIds: finds the canonical `ci` job id", () => {
55
+ const yaml = `name: CI\non:\n pull_request:\njobs:\n ci:\n uses: dsj1984/mandrel-platform/.github/workflows/pr-quality.yml@sha\n ci-required:\n needs: [ci]\n`;
56
+ const ids = extractJobIds(yaml);
57
+ assert.ok(ids.has("ci"));
58
+ assert.ok(ids.has("ci-required"));
59
+ });
60
+
61
+ // ── warnOnNonCanonicalCallerNaming ──────────────────────────────────────────
62
+
63
+ function withTempWorkflowsDir(files, run) {
64
+ const dir = mkdtempSync(join(tmpdir(), "check-required-contexts-test-"));
65
+ try {
66
+ for (const [name, content] of Object.entries(files)) {
67
+ writeFileSync(join(dir, name), content, "utf8");
68
+ }
69
+ return run(dir);
70
+ } finally {
71
+ rmSync(dir, { recursive: true, force: true });
72
+ }
73
+ }
74
+
75
+ test("warnOnNonCanonicalCallerNaming: no warnings for the fully canonical shape", () => {
76
+ withTempWorkflowsDir(
77
+ {
78
+ "ci.yml": `name: CI\non:\n pull_request:\njobs:\n ci:\n uses: dsj1984/mandrel-platform/.github/workflows/pr-quality.yml@sha\n ci-required:\n needs: [ci]\n`,
79
+ },
80
+ (dir) => {
81
+ const workflowFiles = ["ci.yml"];
82
+ const workflowJobMap = new Map([["ci.yml", extractJobIds(
83
+ `name: CI\non:\n pull_request:\njobs:\n ci:\n uses: x\n ci-required:\n needs: [ci]\n`
84
+ )]]);
85
+ const warnings = warnOnNonCanonicalCallerNaming(workflowFiles, workflowJobMap, dir);
86
+ assert.deepEqual(warnings, []);
87
+ }
88
+ );
89
+ });
90
+
91
+ test("warnOnNonCanonicalCallerNaming: warns (does not throw) when the caller file is missing", () => {
92
+ withTempWorkflowsDir({ "quality.yml": "name: quality\non:\njobs:\n quality:\n" }, (dir) => {
93
+ const workflowFiles = ["quality.yml"];
94
+ const workflowJobMap = new Map([["quality.yml", new Set(["quality"])]]);
95
+ const warnings = warnOnNonCanonicalCallerNaming(workflowFiles, workflowJobMap, dir);
96
+ assert.equal(warnings.length, 1);
97
+ assert.match(warnings[0], new RegExp(`no "${CANONICAL_CALLER.file}" file found`));
98
+ });
99
+ });
100
+
101
+ test("warnOnNonCanonicalCallerNaming: warns on a non-canonical display name (athportal shape)", () => {
102
+ withTempWorkflowsDir(
103
+ { "ci.yml": `name: quality\non:\njobs:\n ci:\n uses: x\n` },
104
+ (dir) => {
105
+ const workflowFiles = ["ci.yml"];
106
+ const workflowJobMap = new Map([["ci.yml", new Set(["ci"])]]);
107
+ const warnings = warnOnNonCanonicalCallerNaming(workflowFiles, workflowJobMap, dir);
108
+ assert.equal(warnings.length, 1);
109
+ assert.match(warnings[0], /display name is "quality"/);
110
+ }
111
+ );
112
+ });
113
+
114
+ test("warnOnNonCanonicalCallerNaming: warns on a non-canonical job id (swarm-os shape)", () => {
115
+ withTempWorkflowsDir(
116
+ { "ci.yml": `name: CI\non:\njobs:\n quality:\n uses: x\n ci-required:\n needs: [quality]\n` },
117
+ (dir) => {
118
+ const workflowFiles = ["ci.yml"];
119
+ const workflowJobMap = new Map([["ci.yml", new Set(["quality", "ci-required"])]]);
120
+ const warnings = warnOnNonCanonicalCallerNaming(workflowFiles, workflowJobMap, dir);
121
+ assert.equal(warnings.length, 1);
122
+ assert.match(warnings[0], /has no "ci" job id/);
123
+ }
124
+ );
125
+ });
126
+
127
+ test("warnOnNonCanonicalCallerNaming: warns on both display name and job id together", () => {
128
+ withTempWorkflowsDir(
129
+ { "ci.yml": `name: CI (PR)\non:\njobs:\n build:\n uses: x\n` },
130
+ (dir) => {
131
+ const workflowFiles = ["ci.yml"];
132
+ const workflowJobMap = new Map([["ci.yml", new Set(["build"])]]);
133
+ const warnings = warnOnNonCanonicalCallerNaming(workflowFiles, workflowJobMap, dir);
134
+ assert.equal(warnings.length, 2);
135
+ }
136
+ );
137
+ });