@intentius/chant-lexicon-github 0.49.0 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/dist/components/generate-op-pipeline.d.ts +81 -0
  2. package/dist/components/generate-op-pipeline.d.ts.map +1 -0
  3. package/dist/integrity.json +15 -5
  4. package/dist/lint/audit-catalog.d.ts.map +1 -1
  5. package/dist/lint/post-synth/gha059.d.ts +40 -0
  6. package/dist/lint/post-synth/gha059.d.ts.map +1 -0
  7. package/dist/lint/post-synth/gha060.d.ts +34 -0
  8. package/dist/lint/post-synth/gha060.d.ts.map +1 -0
  9. package/dist/lint/post-synth/gha061.d.ts +27 -0
  10. package/dist/lint/post-synth/gha061.d.ts.map +1 -0
  11. package/dist/lint/post-synth/gha062.d.ts +24 -0
  12. package/dist/lint/post-synth/gha062.d.ts.map +1 -0
  13. package/dist/lint/post-synth/gha063.d.ts +12 -0
  14. package/dist/lint/post-synth/gha063.d.ts.map +1 -0
  15. package/dist/lint/post-synth/gha064.d.ts +12 -0
  16. package/dist/lint/post-synth/gha064.d.ts.map +1 -0
  17. package/dist/lint/post-synth/gha065.d.ts +19 -0
  18. package/dist/lint/post-synth/gha065.d.ts.map +1 -0
  19. package/dist/lint/post-synth/gha066.d.ts +12 -0
  20. package/dist/lint/post-synth/gha066.d.ts.map +1 -0
  21. package/dist/lint/post-synth/gha067.d.ts +12 -0
  22. package/dist/lint/post-synth/gha067.d.ts.map +1 -0
  23. package/dist/lint/post-synth/gha068.d.ts +13 -0
  24. package/dist/lint/post-synth/gha068.d.ts.map +1 -0
  25. package/dist/lint/post-synth/index.d.ts.map +1 -1
  26. package/dist/lint/post-synth/yaml-helpers.d.ts +20 -0
  27. package/dist/lint/post-synth/yaml-helpers.d.ts.map +1 -1
  28. package/dist/lint/rules/data/action-usage-policy.d.ts +30 -0
  29. package/dist/lint/rules/data/action-usage-policy.d.ts.map +1 -0
  30. package/dist/lint/rules/data/advisory-feed.d.ts +36 -0
  31. package/dist/lint/rules/data/advisory-feed.d.ts.map +1 -0
  32. package/dist/manifest.json +109 -1
  33. package/dist/meta.json +5 -0
  34. package/dist/okf/index.md +10 -0
  35. package/dist/okf/rules/GHA059.md +15 -0
  36. package/dist/okf/rules/GHA060.md +15 -0
  37. package/dist/okf/rules/GHA061.md +15 -0
  38. package/dist/okf/rules/GHA062.md +15 -0
  39. package/dist/okf/rules/GHA063.md +15 -0
  40. package/dist/okf/rules/GHA064.md +15 -0
  41. package/dist/okf/rules/GHA065.md +15 -0
  42. package/dist/okf/rules/GHA066.md +15 -0
  43. package/dist/okf/rules/GHA067.md +15 -0
  44. package/dist/okf/rules/GHA068.md +15 -0
  45. package/dist/okf/types/Job.md +9 -0
  46. package/dist/okf/types/Workflow.md +1 -0
  47. package/dist/plugin.d.ts.map +1 -1
  48. package/dist/rules/gha059.ts +132 -0
  49. package/dist/rules/gha060.ts +146 -0
  50. package/dist/rules/gha061.ts +85 -0
  51. package/dist/rules/gha062.ts +68 -0
  52. package/dist/rules/gha063.ts +60 -0
  53. package/dist/rules/gha064.ts +55 -0
  54. package/dist/rules/gha065.ts +129 -0
  55. package/dist/rules/gha066.ts +47 -0
  56. package/dist/rules/gha067.ts +76 -0
  57. package/dist/rules/gha068.ts +51 -0
  58. package/dist/rules/yaml-helpers.ts +37 -2
  59. package/dist/types/index.d.ts +1 -0
  60. package/package.json +2 -2
  61. package/src/codegen/docs.ts +1 -1
  62. package/src/components/generate-op-pipeline.test.ts +146 -0
  63. package/src/components/generate-op-pipeline.ts +183 -0
  64. package/src/generated/index.d.ts +1 -0
  65. package/src/generated/lexicon-github.json +5 -0
  66. package/src/lint/audit-catalog.ts +16 -1
  67. package/src/lint/post-synth/gha059.test.ts +147 -0
  68. package/src/lint/post-synth/gha059.ts +132 -0
  69. package/src/lint/post-synth/gha060.test.ts +144 -0
  70. package/src/lint/post-synth/gha060.ts +146 -0
  71. package/src/lint/post-synth/gha061.test.ts +85 -0
  72. package/src/lint/post-synth/gha061.ts +85 -0
  73. package/src/lint/post-synth/gha062.test.ts +88 -0
  74. package/src/lint/post-synth/gha062.ts +68 -0
  75. package/src/lint/post-synth/gha063.test.ts +86 -0
  76. package/src/lint/post-synth/gha063.ts +60 -0
  77. package/src/lint/post-synth/gha064.test.ts +73 -0
  78. package/src/lint/post-synth/gha064.ts +55 -0
  79. package/src/lint/post-synth/gha065.test.ts +85 -0
  80. package/src/lint/post-synth/gha065.ts +129 -0
  81. package/src/lint/post-synth/gha066.test.ts +66 -0
  82. package/src/lint/post-synth/gha066.ts +47 -0
  83. package/src/lint/post-synth/gha067.test.ts +85 -0
  84. package/src/lint/post-synth/gha067.ts +76 -0
  85. package/src/lint/post-synth/gha068.test.ts +71 -0
  86. package/src/lint/post-synth/gha068.ts +51 -0
  87. package/src/lint/post-synth/index.ts +20 -0
  88. package/src/lint/post-synth/yaml-helpers.ts +37 -2
  89. package/src/lint/rules/data/action-usage-policy.ts +31 -0
  90. package/src/lint/rules/data/advisory-feed.ts +38 -0
  91. package/src/plugin.test.ts +24 -3
  92. package/src/plugin.ts +28 -0
@@ -0,0 +1,76 @@
1
+ /**
2
+ * GHA067: Unconditional Heavy Step With No Path Filter
3
+ *
4
+ * Flags a `docker build`/`docker buildx build` step that has no `if:` guard,
5
+ * in a workflow that triggers on `push`/`pull_request` with no `paths:` or
6
+ * `paths-ignore:` filter. A docs-only or unrelated change still pays for a
7
+ * full image build because nothing scopes the trigger or guards the step.
8
+ * Efficiency (#444), not a correctness or security issue.
9
+ */
10
+
11
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
12
+ import { getPrimaryOutput, parseDoc, jobEntries } from "./yaml-helpers";
13
+
14
+ const DOCKER_BUILD_RE = /\bdocker(\s+buildx)?\s+build\b/i;
15
+
16
+ function triggerObject(on: unknown, key: string): Record<string, unknown> | undefined {
17
+ if (!on || typeof on !== "object" || Array.isArray(on)) return undefined;
18
+ const trig = (on as Record<string, unknown>)[key];
19
+ return trig && typeof trig === "object" && !Array.isArray(trig) ? (trig as Record<string, unknown>) : undefined;
20
+ }
21
+
22
+ function hasPushOrPRTrigger(on: unknown): boolean {
23
+ if (typeof on === "string") return on === "push" || on === "pull_request";
24
+ if (Array.isArray(on)) return on.includes("push") || on.includes("pull_request");
25
+ if (on && typeof on === "object") {
26
+ const keys = Object.keys(on as Record<string, unknown>);
27
+ return keys.includes("push") || keys.includes("pull_request");
28
+ }
29
+ return false;
30
+ }
31
+
32
+ function hasPathFilter(on: unknown): boolean {
33
+ for (const key of ["push", "pull_request"]) {
34
+ const trig = triggerObject(on, key);
35
+ if (!trig) continue;
36
+ if (Array.isArray(trig.paths) && trig.paths.length > 0) return true;
37
+ if (Array.isArray(trig["paths-ignore"]) && trig["paths-ignore"].length > 0) return true;
38
+ }
39
+ return false;
40
+ }
41
+
42
+ export const gha067: PostSynthCheck = {
43
+ id: "GHA067",
44
+ description: "Unconditional docker build with no path filter or guard",
45
+
46
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
47
+ const diagnostics: PostSynthDiagnostic[] = [];
48
+
49
+ for (const [, output] of ctx.outputs) {
50
+ const yaml = getPrimaryOutput(output);
51
+ const doc = parseDoc(yaml);
52
+ if (!doc) continue;
53
+ if (!hasPushOrPRTrigger(doc.on)) continue;
54
+ if (hasPathFilter(doc.on)) continue;
55
+
56
+ for (const [jobName, jobObj] of jobEntries(yaml)) {
57
+ const steps = Array.isArray(jobObj.steps) ? (jobObj.steps as Array<Record<string, unknown>>) : [];
58
+ for (const step of steps) {
59
+ const run = typeof step.run === "string" ? step.run : undefined;
60
+ if (!run || !DOCKER_BUILD_RE.test(run)) continue;
61
+ if (step.if !== undefined) continue; // already guarded
62
+
63
+ diagnostics.push({
64
+ checkId: "GHA067",
65
+ severity: "info",
66
+ message: `Job "${jobName}" runs a Docker build on every push/pull_request with no \`paths:\`/\`paths-ignore:\` filter and no \`if:\` guard — unrelated changes (docs, etc.) still pay for a full image build. Scope the trigger's paths or add a guard.`,
67
+ entity: jobName,
68
+ lexicon: "github",
69
+ });
70
+ }
71
+ }
72
+ }
73
+
74
+ return diagnostics;
75
+ },
76
+ };
@@ -0,0 +1,71 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
+ import { gha068 } from "./gha068";
4
+
5
+ function makeCtx(yaml: string): PostSynthContext {
6
+ return {
7
+ outputs: new Map([["github", yaml]]),
8
+ entities: new Map(),
9
+ buildResult: { outputs: new Map([["github", yaml]]), entities: new Map(), warnings: [], errors: [], sourceFileCount: 1 },
10
+ };
11
+ }
12
+
13
+ describe("GHA068: pull-request workflow missing a concurrency group", () => {
14
+ test("flags a pull_request workflow with no concurrency block", () => {
15
+ const yaml = `name: CI
16
+ on:
17
+ pull_request: {}
18
+ jobs:
19
+ test:
20
+ runs-on: ubuntu-latest
21
+ steps:
22
+ - run: npm test
23
+ `;
24
+ const diags = gha068.check(makeCtx(yaml));
25
+ expect(diags).toHaveLength(1);
26
+ expect(diags[0].checkId).toBe("GHA068");
27
+ expect(diags[0].message).toContain("CI");
28
+ });
29
+
30
+ test("does not flag when a concurrency block is present", () => {
31
+ const yaml = `name: CI
32
+ on:
33
+ pull_request: {}
34
+ concurrency:
35
+ group: \${{ github.workflow }}-\${{ github.ref }}
36
+ cancel-in-progress: true
37
+ jobs:
38
+ test:
39
+ runs-on: ubuntu-latest
40
+ steps:
41
+ - run: npm test
42
+ `;
43
+ expect(gha068.check(makeCtx(yaml))).toHaveLength(0);
44
+ });
45
+
46
+ test("does not flag a deploy workflow (covered by GHA024)", () => {
47
+ const yaml = `name: Deploy
48
+ on:
49
+ pull_request: {}
50
+ jobs:
51
+ deploy:
52
+ runs-on: ubuntu-latest
53
+ steps:
54
+ - run: ./deploy.sh
55
+ `;
56
+ expect(gha068.check(makeCtx(yaml))).toHaveLength(0);
57
+ });
58
+
59
+ test("does not flag a workflow with no pull_request trigger", () => {
60
+ const yaml = `name: CI
61
+ on:
62
+ push: {}
63
+ jobs:
64
+ test:
65
+ runs-on: ubuntu-latest
66
+ steps:
67
+ - run: npm test
68
+ `;
69
+ expect(gha068.check(makeCtx(yaml))).toHaveLength(0);
70
+ });
71
+ });
@@ -0,0 +1,51 @@
1
+ /**
2
+ * GHA068: Pull-Request Workflow Missing a Concurrency Group
3
+ *
4
+ * Flags a workflow triggered on `pull_request` with no top-level
5
+ * `concurrency:` block. Without one, a superseded push keeps its old run
6
+ * going instead of cancelling it, so every commit on a PR pays for the full
7
+ * pipeline instead of just the latest. Deploy workflows are left to GHA024,
8
+ * which already covers them (overlapping-deployment risk, not capacity).
9
+ * Efficiency (#444), not a correctness or security issue.
10
+ */
11
+
12
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
13
+ import { getPrimaryOutput, parseDoc, extractJobs, extractWorkflowName } from "./yaml-helpers";
14
+
15
+ function hasPullRequestTrigger(on: unknown): boolean {
16
+ if (typeof on === "string") return on === "pull_request";
17
+ if (Array.isArray(on)) return on.includes("pull_request");
18
+ if (on && typeof on === "object") return "pull_request" in (on as Record<string, unknown>);
19
+ return false;
20
+ }
21
+
22
+ export const gha068: PostSynthCheck = {
23
+ id: "GHA068",
24
+ description: "Pull-request workflow missing a concurrency group",
25
+
26
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
27
+ const diagnostics: PostSynthDiagnostic[] = [];
28
+
29
+ for (const [, output] of ctx.outputs) {
30
+ const yaml = getPrimaryOutput(output);
31
+ const doc = parseDoc(yaml);
32
+ if (!doc || !hasPullRequestTrigger(doc.on)) continue;
33
+
34
+ const workflowName = extractWorkflowName(yaml) ?? "";
35
+ const jobNames = [...extractJobs(yaml).keys()];
36
+ const isDeployWorkflow = /deploy/i.test(workflowName) || jobNames.some((n) => /deploy/i.test(n));
37
+ if (isDeployWorkflow) continue; // GHA024 already covers deploy workflows
38
+
39
+ if (!/^\s*concurrency:/m.test(yaml)) {
40
+ diagnostics.push({
41
+ checkId: "GHA068",
42
+ severity: "info",
43
+ message: `Workflow${workflowName ? ` "${workflowName}"` : ""} triggers on pull_request with no \`concurrency:\` group — a new push doesn't cancel the superseded run, so every commit on the PR consumes a full run's worth of runner capacity. Add a \`concurrency:\` group with \`cancel-in-progress: true\`.`,
44
+ lexicon: "github",
45
+ });
46
+ }
47
+ }
48
+
49
+ return diagnostics;
50
+ },
51
+ };
@@ -45,6 +45,16 @@ import { gha055 } from "./gha055";
45
45
  import { gha056 } from "./gha056";
46
46
  import { gha057 } from "./gha057";
47
47
  import { gha058 } from "./gha058";
48
+ import { gha059 } from "./gha059";
49
+ import { gha060 } from "./gha060";
50
+ import { gha061 } from "./gha061";
51
+ import { gha062 } from "./gha062";
52
+ import { gha063 } from "./gha063";
53
+ import { gha064 } from "./gha064";
54
+ import { gha065 } from "./gha065";
55
+ import { gha066 } from "./gha066";
56
+ import { gha067 } from "./gha067";
57
+ import { gha068 } from "./gha068";
48
58
 
49
59
  export const postSynthChecks: PostSynthCheck[] = [
50
60
  gha006,
@@ -92,4 +102,14 @@ export const postSynthChecks: PostSynthCheck[] = [
92
102
  gha056,
93
103
  gha057,
94
104
  gha058,
105
+ gha059,
106
+ gha060,
107
+ gha061,
108
+ gha062,
109
+ gha063,
110
+ gha064,
111
+ gha065,
112
+ gha066,
113
+ gha067,
114
+ gha068,
95
115
  ];
@@ -172,7 +172,8 @@ export interface ImageRef {
172
172
  source: "container" | "service" | "step";
173
173
  }
174
174
 
175
- function parseDoc(yaml: string): Record<string, unknown> | undefined {
175
+ /** Parse a workflow YAML into a structured document, tolerant of parse errors. */
176
+ export function parseDoc(yaml: string): Record<string, unknown> | undefined {
176
177
  try {
177
178
  return parseYAML(yaml);
178
179
  } catch {
@@ -180,7 +181,8 @@ function parseDoc(yaml: string): Record<string, unknown> | undefined {
180
181
  }
181
182
  }
182
183
 
183
- function jobEntries(yaml: string): Array<[string, Record<string, unknown>]> {
184
+ /** Every job in the workflow as `[name, rawJobObject]`, via the structural parser. */
185
+ export function jobEntries(yaml: string): Array<[string, Record<string, unknown>]> {
184
186
  const doc = parseDoc(yaml);
185
187
  const jobs = doc?.jobs;
186
188
  if (!jobs || typeof jobs !== "object") return [];
@@ -193,6 +195,26 @@ function jobEntries(yaml: string): Array<[string, Record<string, unknown>]> {
193
195
  return out;
194
196
  }
195
197
 
198
+ /**
199
+ * Every step object (raw, as parsed from YAML — `uses`, `with`, `id`, `run`,
200
+ * `env`, etc.) grouped by owning job. Unlike {@link extractJobs} (which only
201
+ * lifts `uses`/`run`/`name`), this hands back each step's full object so a
202
+ * check can read arbitrary keys (`with:` inputs, `id:`) without growing a new
203
+ * bespoke regex extractor per key. See GHA060.
204
+ */
205
+ export function extractStepsByJob(yaml: string): Map<string, Array<Record<string, unknown>>> {
206
+ const out = new Map<string, Array<Record<string, unknown>>>();
207
+ for (const [job, jobObj] of jobEntries(yaml)) {
208
+ const steps = jobObj.steps;
209
+ if (!Array.isArray(steps)) continue;
210
+ out.set(
211
+ job,
212
+ steps.filter((s): s is Record<string, unknown> => !!s && typeof s === "object" && !Array.isArray(s)),
213
+ );
214
+ }
215
+ return out;
216
+ }
217
+
196
218
  /**
197
219
  * Extract every `uses:` reference from the workflow — both step-level actions
198
220
  * and job-level reusable-workflow calls. Uses the structural YAML parser so
@@ -462,6 +484,19 @@ export function stripUsesComment(uses: string): string {
462
484
  return uses.replace(/\s+#.*$/, "").trim();
463
485
  }
464
486
 
487
+ /**
488
+ * Extract a `uses:` value's trailing inline comment, if any — the
489
+ * human-readable version label reviewers rely on when a ref is pinned to an
490
+ * opaque commit SHA (e.g. `actions/setup-node@1a2b…9a0b # v4.0.2` → `v4.0.2`).
491
+ * Returns undefined for no comment or an empty (`# ` with nothing after it)
492
+ * one, since both mean "no annotation" to a reviewer. See GHA059.
493
+ */
494
+ export function extractUsesComment(rawUses: string): string | undefined {
495
+ const m = rawUses.match(/\s+#\s*(.*)$/);
496
+ const comment = m?.[1]?.trim();
497
+ return comment ? comment : undefined;
498
+ }
499
+
465
500
  export function parseActionUses(rawUses: string): { owner: string; repo: string; slug: string; gitRef: string } | undefined {
466
501
  const uses = stripUsesComment(rawUses);
467
502
  if (uses.startsWith("./") || uses.startsWith("../") || uses.startsWith("docker://")) return undefined;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Default action-usage policy for GHA061 — opt-in, so it ships with an empty
3
+ * allow/deny list: with nothing configured, GHA061 reports nothing (#445
4
+ * acceptance criteria: "silent when unconfigured").
5
+ *
6
+ * `PostSynthContext` carries no per-check runtime config channel today (see
7
+ * `trusted-action-owners.ts`'s doc for the same constraint), so — like that
8
+ * file — this is a vendored, code-level default. A project that wants a real
9
+ * policy doesn't edit this file: it authors a `lint.policies` entry
10
+ * (`chant.config.ts`'s `lint.policies`, see `packages/core/src/lint/config.ts`)
11
+ * that imports `evaluateUsagePolicy` from `../../post-synth/gha061` and calls
12
+ * it with its own `ActionUsagePolicy`, wrapped in a `PostSynthCheck`. That is
13
+ * the supported opt-in path; this default only documents the "nothing
14
+ * configured" shape and is what the shipped GHA061 check itself passes.
15
+ */
16
+
17
+ /**
18
+ * An opt-in allow/deny policy over third-party action references.
19
+ * Entries match an exact `owner/repo` slug, a bare `owner` (every repo under
20
+ * that owner), or an `owner/*` wildcard (equivalent to a bare owner, offered
21
+ * for readability). `deny` always wins over `allow` for an entry that somehow
22
+ * appears in both.
23
+ */
24
+ export interface ActionUsagePolicy {
25
+ /** If set, any reference whose owner/slug isn't matched here is flagged. Unset = no allowlist restriction. */
26
+ allow?: string[];
27
+ /** Always flagged, regardless of `allow`. */
28
+ deny?: string[];
29
+ }
30
+
31
+ export const DEFAULT_ACTION_USAGE_POLICY: ActionUsagePolicy = {};
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Default advisory feed for GHA062 — ships empty, so the check degrades to
3
+ * "no findings" rather than erroring when nothing is supplied (#445
4
+ * acceptance criteria: "degrades gracefully when the feed is unreachable").
5
+ *
6
+ * Per epic #350's design spine, the pure core never fetches: a live feed is
7
+ * the CALLER's concern (a `chant audit` CLI flag reading a local JSON file,
8
+ * a pre-fetched structure a hosted service passes in) — never a `fetch()`
9
+ * inside a rule. This module documents the "nothing supplied" shape and is
10
+ * what the shipped GHA062 check itself passes; a caller with a real feed
11
+ * calls `checkAdvisories` (`../../post-synth/gha062`) directly with its own
12
+ * `AdvisoryFeed`, the same way a `lint.policies` entry supplies GHA061 its
13
+ * own `ActionUsagePolicy` (see `action-usage-policy.ts`).
14
+ */
15
+
16
+ /** One disclosed advisory affecting a pinned action reference. */
17
+ export interface AdvisoryEntry {
18
+ /** `owner/repo` slug the advisory applies to. */
19
+ slug: string;
20
+ /** Affected commit SHA(s) — for SHA-pinned refs. */
21
+ shas?: string[];
22
+ /** Affected tag/branch ref(s) — for refs still pinned to a mutable tag. */
23
+ refs?: string[];
24
+ /** Advisory identifier (e.g. a GHSA id). */
25
+ id: string;
26
+ /** Human-readable summary of the issue. */
27
+ summary: string;
28
+ /** A ref/SHA known to be patched, if the feed carries one. */
29
+ patchedRef?: string;
30
+ /** Link to the advisory. */
31
+ url?: string;
32
+ }
33
+
34
+ export interface AdvisoryFeed {
35
+ entries: AdvisoryEntry[];
36
+ }
37
+
38
+ export const DEFAULT_ADVISORY_FEED: AdvisoryFeed = { entries: [] };
@@ -24,7 +24,7 @@ describe("githubPlugin", () => {
24
24
 
25
25
  test("provides post-synth checks", () => {
26
26
  const checks = githubPlugin.postSynthChecks!();
27
- expect(checks.length).toBe(45);
27
+ expect(checks.length).toBe(55);
28
28
 
29
29
  const checkIds = checks.map((c) => c.id);
30
30
  expect(checkIds).toContain("GHA006");
@@ -85,8 +85,29 @@ describe("githubPlugin", () => {
85
85
  // chant #1069 — registered as the `Expression` class (capitalized), the
86
86
  // real export of src/index.ts; "expression" (lowercase) was never exported.
87
87
  const intrinsics = githubPlugin.intrinsics!();
88
- expect(intrinsics.length).toBe(1);
89
- expect(intrinsics[0].name).toBe("Expression");
88
+ expect(intrinsics.find((i) => i.name === "Expression")).toEqual({
89
+ name: "Expression",
90
+ description: expect.any(String),
91
+ outputKey: "expression",
92
+ isTag: false,
93
+ });
94
+
95
+ // chant #1966 — src/expression.ts's pure context-accessor and condition
96
+ // helpers, registered `foldsEagerly` rather than `foldsAsCall` (see
97
+ // ../../../packages/core/src/lexicon.ts's IntrinsicDef.foldsEagerly).
98
+ const eagerNames = [
99
+ "secrets", "matrix", "steps", "needs", "inputs", "vars", "env",
100
+ "always", "failure", "success", "cancelled",
101
+ "contains", "startsWith", "toJSON", "fromJSON", "format",
102
+ "branch", "tag",
103
+ ];
104
+ for (const name of eagerNames) {
105
+ const def = intrinsics.find((i) => i.name === name);
106
+ expect(def, `missing intrinsic registration for "${name}"`).toBeDefined();
107
+ expect(def!.isTag).toBe(false);
108
+ expect(def!.foldsEagerly).toBe(true);
109
+ }
110
+ expect(intrinsics.length).toBe(1 + eagerNames.length);
90
111
  });
91
112
 
92
113
  test("provides init templates", () => {
package/src/plugin.ts CHANGED
@@ -32,12 +32,15 @@ import { GitHubActionsParser } from "./import/parser";
32
32
  import { GitHubActionsGenerator } from "./import/generator";
33
33
  import { githubContextTools } from "./mcp/context-tools";
34
34
  import { generateGithubPipeline } from "./components/generate-pipeline";
35
+ import { generateGithubOpPipeline } from "./components/generate-op-pipeline";
35
36
 
36
37
  export const githubPlugin: LexiconPlugin = {
37
38
  name: "github",
38
39
  auditCatalog: () => githubAuditCatalog,
39
40
  // Generate mode (#891): synthesize a GitHub Actions workflow from the component graph.
40
41
  generateComponentPipeline: (components, options) => generateGithubPipeline(components, options),
42
+ // Generate mode, Op counterpart (#927): synthesize one cron-triggered workflow per scheduled Op.
43
+ generateOpPipeline: (ops, options) => generateGithubOpPipeline(ops, options),
41
44
  serializer: githubSerializer,
42
45
 
43
46
  lintRules(): LintRule[] {
@@ -70,6 +73,31 @@ export const githubPlugin: LexiconPlugin = {
70
73
  outputKey: "expression",
71
74
  isTag: false,
72
75
  },
76
+ // chant #1966 — src/expression.ts's context-accessor and condition
77
+ // helpers, each a pure function of its arguments building an
78
+ // `Expression` (or, for `steps`/`needs`, a plain object of one).
79
+ // `foldsEagerly: true` (not `foldsAsCall`) because their usual use is
80
+ // embedded directly in a template literal (`` `${matrix("os")}` ``),
81
+ // which needs the real, already-live `Expression` at fold time to
82
+ // stringify correctly — see IntrinsicDef.foldsEagerly's doc.
83
+ { name: "secrets", description: "Access a secret by name.", isTag: false, foldsEagerly: true },
84
+ { name: "matrix", description: "Access a matrix value by key.", isTag: false, foldsEagerly: true },
85
+ { name: "steps", description: "Access a step output.", isTag: false, foldsEagerly: true },
86
+ { name: "needs", description: "Access a job output from a needed job.", isTag: false, foldsEagerly: true },
87
+ { name: "inputs", description: "Access a workflow input.", isTag: false, foldsEagerly: true },
88
+ { name: "vars", description: "Access a configuration variable.", isTag: false, foldsEagerly: true },
89
+ { name: "env", description: "Access an environment variable.", isTag: false, foldsEagerly: true },
90
+ { name: "always", description: "Always run, regardless of status.", isTag: false, foldsEagerly: true },
91
+ { name: "failure", description: "Run only if a previous step has failed.", isTag: false, foldsEagerly: true },
92
+ { name: "success", description: "Run only if all previous steps succeeded.", isTag: false, foldsEagerly: true },
93
+ { name: "cancelled", description: "Run only if the workflow was cancelled.", isTag: false, foldsEagerly: true },
94
+ { name: "contains", description: "Check if a string contains a substring.", isTag: false, foldsEagerly: true },
95
+ { name: "startsWith", description: "Check if a string starts with a prefix.", isTag: false, foldsEagerly: true },
96
+ { name: "toJSON", description: "Convert a value to JSON.", isTag: false, foldsEagerly: true },
97
+ { name: "fromJSON", description: "Parse a JSON string.", isTag: false, foldsEagerly: true },
98
+ { name: "format", description: "Format a string with placeholders.", isTag: false, foldsEagerly: true },
99
+ { name: "branch", description: "Check if the ref matches a branch name.", isTag: false, foldsEagerly: true },
100
+ { name: "tag", description: "Check if the ref matches a tag prefix.", isTag: false, foldsEagerly: true },
73
101
  ];
74
102
  },
75
103