diff --git a/.gitignore b/.gitignore index 95001ee..613ffa8 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ coverage/ .internal/ .vercel .roast/ + +.worktrees/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 85da531..92fac66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,11 +16,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`--exit-code`** flag: exit `3` if any workflow is invalid, `4` if nothing fires — for using actwhy as a CI gate. Default behavior is unchanged (always exit `0`). - Workflow `name:` is now read and exposed on each verdict (`--json`). +- Commit-message skip directives are evaluated for `push` and `pull_request`, including the `skip-checks: true` trailer. +- GitHub object-filter expressions such as `pull_request.labels.*.name` are evaluated when payload values are known. ### Changed -- A static matrix exceeding GitHub's 256-job cap now emits a `matrix-over-limit` warning instead of reporting the job as firing. -- A workflow with both `paths` and `paths-ignore` on one event now warns (`paths-and-paths-ignore`) and follows GitHub (uses `paths`). +- Slash-delimited globstars now match zero directories, so `docs/**/*.md` includes `docs/README.md`. +- Tag pushes ignore path filters, matching GitHub, and known-empty diffs skip path-filtered workflows. +- A static matrix exceeding GitHub's 256-job cap is now a `matrix-over-limit` job error and contributes zero firing variants. +- Mutually exclusive include/ignore filter pairs are reported as invalid instead of receiving invented precedence. +- An in-sync branch now reports zero outgoing files instead of silently substituting the last commit. - `--event` payloads are validated (must be a JSON object, size-capped) and `__proto__`/`constructor` keys are ignored. - The CLI errors clearly on Node < 20; CI actions are pinned by commit SHA. @@ -48,7 +53,7 @@ Initial release. - `schedule`, `merge_group`, `workflow_run`, and `workflow_call` are classified but not evaluated. - Concurrency is not modeled; step-level `env:` is not evaluated; verdicts assume runs succeed. -- The >1,000-changed-files paths-filter skip that GitHub performs is not modeled. +- GitHub's >1,000-commit/diff-timeout fallback and 3,000-file path-filter window are not modeled. See [docs/limitations.md](docs/limitations.md) for the full list and what actwhy reports in each case. diff --git a/README.md b/README.md index 7bc6644..aba0cd7 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ `act` executes your workflows (heavy, needs Docker, breaks on runner mismatches). `actionlint` lints their syntax. GitHub's own UI only explains what fired *after* you push. **actwhy** answers a different question — *for this exact push or PR, which workflows fire, which are skipped and by which filter, and which can't be decided offline* — statically, instantly, and with zero network calls. -It never guesses. Every workflow gets one of three verdicts: **FIRES**, **SKIPPED** (with the failing filter quoted in plain English), or **UNKNOWN** (naming the exact runtime-only value it would need, such as `secrets.*` or `needs.*.outputs.*`, and how to supply it). +It never guesses. Every workflow gets one of four verdicts: **FIRES**, **SKIPPED** (with the failing filter quoted in plain English), **UNKNOWN** (naming the exact runtime-only value it would need, such as `secrets.*` or `needs.*.outputs.*`, and how to supply it), or **ERROR** for a configuration GitHub would reject. ![actwhy demo](docs/assets/demo.png) @@ -38,10 +38,6 @@ npm install -g actwhy actwhy --help ``` -> **Not on npm yet?** actwhy publishes to npm immediately after launch. Until then, install -> from source: `git clone https://github.com/Co-Messi/actwhy && cd actwhy && npm install && npm run build`, -> then run `node dist/actwhy.js` (or `npm link` to expose the `actwhy` command). - Requires **Node.js ≥ 20**. ## What it looks like @@ -73,7 +69,8 @@ And the case above, where the closest miss is surfaced so you know which filter - **It never guesses.** A value it cannot know offline (a secret, a needed job's output) becomes an honest `UNKNOWN` naming that value — not a fabricated pass or fail. - **It quotes the exact filter that decided the outcome.** Not "skipped" — *"branches filter `["main"]` does not match `feat/login`"*. - **It uses GitHub's own parsing and expression semantics.** Workflow parsing and expression coercion run on GitHub's MIT-licensed [`@actions/workflow-parser`](https://github.com/actions/languageservices) and [`@actions/expressions`](https://github.com/actions/languageservices) — the libraries behind the official Actions language services, not a reimplementation. -- **It gets filter patterns right.** GitHub's `?` and `+` are regex-style quantifiers on the *preceding character*, not glob wildcards — a distinction most third-party matchers get wrong. actwhy implements GitHub's exact semantics, including `!` negation ordering. +- **It gets filter patterns right.** GitHub's `?` and `+` are regex-style quantifiers on the *preceding character*, not glob wildcards — a distinction most third-party matchers get wrong. actwhy implements GitHub's semantics, including `!` negation ordering and slash-delimited globstars such as `docs/**/*.md` matching files directly under `docs/`. +- **It models GitHub's quiet skip rules.** Tag pushes ignore path filters, empty diffs do not start path-filtered workflows, and commit messages containing `[skip ci]`-style directives suppress `push` and `pull_request` runs. - **It catches the classic always-true footgun.** `if: ${{ github.ref }} == 'refs/heads/main'` renders to a non-empty string and is *always* truthy — actwhy warns instead of letting it silently pass. - **It runs fully local.** Zero network calls, zero telemetry. It reads your workflow files and git metadata, nothing else. @@ -99,7 +96,7 @@ actwhy has two subcommands. `push` is the default when you run `actwhy` with no ### `actwhy push` -Simulate a push. With no flags it infers the current branch and the outgoing changed files from git (your branch vs its upstream). +Simulate a push. With no flags it infers the current branch and the outgoing changed files from git (your branch vs its upstream). If the branch is already in sync, actwhy reports an explicit empty outgoing set; it never substitutes an already-pushed commit. | Flag | Description | |---|---| @@ -134,9 +131,10 @@ Simulate a pull request against a base branch. ## How it works -- **Three-valued verdicts.** Each workflow and job resolves to `FIRES`, `SKIPPED`, or `UNKNOWN`. `SKIPPED` always carries the exact failing filter or subexpression; `UNKNOWN` always names the runtime-only value it lacks and how to supply it (`--event payload.json`). actwhy never fabricates a pass or fail. +- **Honest verdicts.** Each workflow and job resolves to `FIRES`, `SKIPPED`, `UNKNOWN`, or `ERROR`. `SKIPPED` carries the exact failing filter or subexpression; `UNKNOWN` names the runtime-only value it lacks and how to supply it (`--event payload.json`); `ERROR` identifies invalid configuration such as mutually exclusive include/ignore filters or a matrix above 256 jobs. - **GitHub's own libraries.** Parsing and expression coercion run on GitHub's MIT-licensed `@actions/workflow-parser` and `@actions/expressions`, so the grammar, type coercion, and function semantics match what GitHub actually does — this is GitHub's code, not a reimplementation. -- **An exact filter-pattern engine.** `*` and `**`, character classes `[…]`, `!` negation (order-sensitive), and — critically — the regex-style quantifiers `?` (zero or one of the *preceding* character) and `+` (one or more of the *preceding* character), which standard glob libraries misinterpret as wildcards. +- **An exact filter-pattern engine.** `*` and `**` (including zero-directory slash-delimited globstars), character classes `[…]`, `!` negation (order-sensitive), and — critically — the regex-style quantifiers `?` (zero or one of the *preceding* character) and `+` (one or more of the *preceding* character), which standard glob libraries misinterpret as wildcards. +- **GitHub object filters.** Expressions such as `github.event.pull_request.labels.*.name` are projected and evaluated when the payload values are known. - **Kleene (three-valued) logic.** Unknowns propagate only when they change the outcome. ` && false` is still a decisive `SKIPPED`; ` || true` is still `FIRES`. A verdict becomes `UNKNOWN` only when the unknown genuinely decides it. For the precise boundaries of v0.1 — which events are evaluated versus classified, and what actwhy reports instead of guessing — see **[docs/limitations.md](docs/limitations.md)**. diff --git a/REVIEW.md b/REVIEW.md index c8a6f62..82f3a98 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -14,7 +14,7 @@ was fixed, and what remains — honestly. |---|---|---| | 1 | A single malformed filter pattern (e.g. `paths: ['src/[']`) threw out of the evaluator and crashed the entire run — every other workflow lost its verdict | Pattern-compilation failures now degrade to a per-workflow `invalid workflow` verdict quoting the error; the rest of the run is unaffected | | 2 | The playground's install section claimed the CLI could read "YAML on stdin" — no stdin handling exists | Claim removed (stdin support is tracked as a starter issue) | -| 3 | The most common stranger flow — running `actwhy` on a branch in sync with its upstream — produced a false "NOTHING fires" from an empty changed-file set | In-sync branches now simulate the last commit's push, with an explicit header label saying so | +| 3 | The most common stranger flow — running `actwhy` on a branch in sync with its upstream — was ambiguous | In-sync branches now report a truthful empty outgoing set with an explicit header label; already-pushed files are never substituted | | 4 | Site footer and JSON-LD pointed at an npm package page that 404s pre-publish | Repointed to the GitHub repository until the npm release is live | | 5 | The social-preview image had overlapping, unreadable text (font-metric assumptions) | Layout rebuilt with fixed columns and end-anchored badges; re-rendered and visually verified | | 6 | A launch-copy draft overstated provenance ("filter semantics are GitHub's code" — the filter-pattern engine is actwhy's own implementation of GitHub's documented grammar) | Corrected; the precise split (GitHub's libraries for parsing/expressions, actwhy's verified engine for filter patterns) is stated everywhere | @@ -50,8 +50,8 @@ was fixed, and what remains — honestly. stay `UNKNOWN` by design. - Step-level `env:` is not evaluated; matrix-dependent step conditions report `UNKNOWN`. -- GitHub skips paths filters on pushes touching >1000 files; actwhy does not - model that edge (documented). +- GitHub's >1,000-commit/diff-timeout fallback and 3,000-file path-filter window + are not modeled (documented). - The web playground share-link format has no versioning guarantee yet. Fidelity disputes are P1 bugs — file a @@ -67,8 +67,8 @@ plus several improvements. All were fixed and covered by regression tests. |---|---|---| | **Critical** | The earlier ReDoS fix only special-cased star atoms; `branches: ['a++']` still compiled to a nested-quantifier `RegExp` and hung the process for minutes on a ~30-char value (uncatchable — a hang, not a throw). | Replaced the RegExp compiler with a **linear-time Thompson-NFA matcher**. Backtracking is impossible by construction; the reviewer's 120 s hang is now ~2 ms. | | **High** | Attacker-controlled workflow text (filter patterns, branch names, commit messages, parser errors) was written to the terminal verbatim — a crafted workflow could inject ANSI escapes to forge a green `FIRES` line. | `render.ts` now strips all C0/C1 control chars and `ESC` from every attacker-derived string before output. Verified: zero `0x1b` bytes reach stdout for a malicious workflow. | -| Medium | A static matrix of 257–4096 legs was reported as firing; GitHub caps matrices at 256 jobs (and fails the run). | Emit a `matrix-over-limit` warning at >256. | -| Medium | `paths` + `paths-ignore` on one event silently dropped `paths-ignore`. | Follow GitHub (use `paths`) and warn (`paths-and-paths-ignore`). | +| Medium | A static matrix of 257–4096 legs was reported as firing; GitHub caps matrices at 256 jobs (and fails the run). | Report the job as a `matrix-over-limit` error and count zero firing variants. | +| Medium | `paths` + `paths-ignore` on one event silently dropped `paths-ignore`. | Report mutually exclusive include/ignore pairs as invalid instead of inventing precedence. | | Medium | The CLI always exited `0`, so it couldn't gate CI. | Added `--exit-code` (3 = invalid workflow, 4 = nothing fires). | | Low | `--event` JSON was unvalidated/unbounded; `__proto__` keys merged; Node < 20 crashed cryptically; git base ref could be a flag; `workflowName()` was a dead stub; CI actions pinned by tag. | Validated/size-capped `--event` + forbidden-key filter; clear Node-version guard; `--end-of-options` on git diffs; wired `name:`; SHA-pinned CI actions. | diff --git a/docs/limitations.md b/docs/limitations.md index 2ccd456..fca84bf 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -1,4 +1,4 @@ -# Limitations (v0.1) +# Limitations (v0.1.x) actwhy's value comes from being *honest*: where it cannot decide something offline, it says so rather than guessing. This page lists the boundaries of the v0.1 release and, for each, exactly what actwhy reports instead of inventing an answer. @@ -61,7 +61,7 @@ Step-level `env:` is not evaluated. Conditions that read a variable set by an ea Static matrices are expanded and counted. A matrix built from `fromJSON(...)` or another dynamic source is not enumerated. -**What actwhy says instead of guessing:** the job's matrix count is reported as `unknown` with a note, rather than a fabricated variant count. If a static matrix would produce **more than 256 jobs** — GitHub's hard cap, which fails the run — actwhy emits a `matrix-over-limit` warning instead of quietly reporting the job as firing. +**What actwhy says instead of guessing:** the job's matrix count is reported as `unknown` with a note, rather than a fabricated variant count. If a static matrix would produce **more than 256 jobs** — GitHub's hard cap — actwhy reports that job as an `ERROR` with `matrix-over-limit` and does not count any of its variants as firing. ## Concurrency @@ -69,21 +69,29 @@ Static matrices are expanded and counted. A matrix built from `fromJSON(...)` or **What actwhy says instead of guessing:** concurrency is simply not part of the verdict. A workflow that would be triggered is reported as `FIRES` regardless of whether a concurrency rule might later cancel it. -### `paths` and `paths-ignore` together +### Mutually exclusive include/ignore filters -GitHub documents `paths` and `paths-ignore` as mutually exclusive on the same event; when both are present it applies `paths` and ignores `paths-ignore`. +GitHub does not allow `paths` with `paths-ignore`, `branches` with `branches-ignore`, or `tags` with `tags-ignore` on the same event. -**What actwhy says instead of guessing:** it follows GitHub (evaluates `paths`, drops `paths-ignore`) and emits a `paths-and-paths-ignore` warning so you know half the author's intent is being discarded. +**What actwhy says instead of guessing:** the workflow is reported as an `ERROR` with `invalid-filter-combination`. actwhy does not invent precedence for an invalid trigger. + +### Commit-message skip directives + +GitHub suppresses `push` and `pull_request` workflows for the documented bracketed directives (`[skip ci]`, `[ci skip]`, `[no ci]`, `[skip actions]`, and `[actions skip]`) and a final `skip-checks: true` trailer. These directives do not apply to `pull_request_target`. + +actwhy evaluates these directives when the commit message is supplied or inferred from `HEAD`. ## Exit codes By default actwhy always exits `0` — a skipped or nothing-fires result is information, not a failure. Pass `--exit-code` to use it as a CI gate: it then exits `3` if any workflow is invalid and `4` if nothing fires. Usage errors (`2`) and a missing workflows directory (`1`) are always non-zero. -## Paths filters on large pushes +## Paths filters on large diffs + +GitHub always runs a workflow when it cannot generate the diff because the push contains **more than 1,000 commits** or diff generation times out. Separately, GitHub evaluates path filters against only the first **3,000 changed files** returned by the diff. -GitHub stops applying `paths` / `paths-ignore` filters on a push that changes **more than 1,000 files**, running the workflow regardless. actwhy does not model this edge case; it applies paths filters to whatever changed-file list it is given. +actwhy does not know whether GitHub's diff timed out and does not truncate an explicitly supplied file list to 3,000 entries. -**What actwhy says instead of guessing:** for pushes near or above that threshold, treat a `paths`-based `SKIPPED` with suspicion — GitHub may run the workflow anyway. This is a known modeling gap, noted here rather than silently mis-reported. +**What actwhy says instead of guessing:** actwhy evaluates the complete changed-file list it receives. For pushes above 1,000 commits, timed-out diffs, or cases where a relevant path may fall beyond GitHub's 3,000-file window, compare the verdict with these documented GitHub limits. ## Fidelity diff --git a/docs/plans/2026-07-24-review-remediation-design.md b/docs/plans/2026-07-24-review-remediation-design.md new file mode 100644 index 0000000..d59a830 --- /dev/null +++ b/docs/plans/2026-07-24-review-remediation-design.md @@ -0,0 +1,78 @@ +# Review Remediation Design + +## Objective + +Make actwhy's published promise—predict GitHub Actions trigger decisions without +guessing—true across every issue reproduced in the full review, then tighten the +playground so a first-time visitor reaches a personal result quickly on desktop +and mobile. + +## Scope + +### Fidelity and CLI correctness + +- Match slash-delimited `**` patterns such as `docs/**/*.md` exactly. +- Ignore `paths` and `paths-ignore` for tag pushes. +- Honor GitHub commit-message skip directives for `push` and `pull_request`, + while leaving `pull_request_target` unaffected. +- Skip path-filtered workflows when the changed-file set is known empty. +- Treat matrices over GitHub's 256-job limit as job errors rather than firing + variants. +- Stop silently substituting the last commit when an upstream branch has no + outgoing commits; report a truthful no-pending-push state. +- Support filtered-array expressions such as + `github.event.pull_request.labels.*.name`. +- Correct limitations and release guidance that currently overstate fidelity. + +### Playground experience + +- Make the playground—not installation or starring—the primary hero action. +- Give first-time visitors a short path from the sample to their own workflow. +- Add contextual next actions for skipped or unknown verdicts. +- Keep a compact verdict visible after mobile edits. +- Make share-link privacy language explicit about URL exposure. +- Reserve animation for meaningful verdict changes and respect reduced-motion + preferences. + +## Approaches considered + +1. **Patch only the four reproduced divergences.** Fastest, but it leaves + adjacent correctness and UX claims from the same review unresolved. +2. **Rewrite the evaluator around another glob/expression package.** Broader + replacement, but it sacrifices the existing small, audited core and creates + unnecessary migration risk. +3. **Recommended: targeted fidelity hardening plus bounded UX polish.** Preserve + the current architecture, add failing regression tests for every behavior, + repair the smallest responsible components, and expand the playground + without adding accounts, telemetry, persistence, or backend state. + +## Architecture + +The browser-safe `src/core` and Node-only `src/cli` boundary remains unchanged. +Pattern matching gains an explicit slash-aware globstar token. Trigger +evaluation gains event-level preconditions before branch/path filters. +Expression evaluation implements the parser's filtered-array visitor instead +of treating it as an evaluation error. + +The web remains static and client-side. New onboarding and resolution actions +operate only on the existing `AppState`; they do not persist workflow content +or make network requests. + +## Error handling + +- Unsupported or invalid constructs resolve to a structured `UNKNOWN` or + `ERROR`, never a confident `FIRES`. +- Known GitHub-wide suppression such as `[skip ci]` produces a structured skip + reason. +- No-pending-push inference is explicit in the CLI header and does not + fabricate the last commit as an outgoing push. +- Share copy states that YAML and event data are embedded in the URL. + +## Verification + +- Red/green tests for every fidelity and CLI behavior. +- Full typecheck, unit/e2e suite, CLI build, web build, and package dry run. +- Browser checks at desktop and mobile widths, keyboard operation, reduced + motion, console/network errors, and the main edit-to-verdict flow. +- Current npm advisory audit. +- Clean branch diff, pushed branch, and an open PR with no merge action. diff --git a/docs/plans/2026-07-24-review-remediation-implementation.md b/docs/plans/2026-07-24-review-remediation-implementation.md new file mode 100644 index 0000000..7cb9f01 --- /dev/null +++ b/docs/plans/2026-07-24-review-remediation-implementation.md @@ -0,0 +1,152 @@ +# Review Remediation Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Repair every confirmed review finding and make the static playground's first-use, resolution, mobile, privacy, and motion experience launch-ready. + +**Architecture:** Preserve the browser-safe core and Node-only CLI split. Add focused structured reasons and state transitions rather than broad new abstractions; keep all web behavior client-side and derive resolution actions from the existing report/state. + +**Tech Stack:** TypeScript, Node.js 20+, Vitest, GitHub `@actions/workflow-parser` and `@actions/expressions`, esbuild, static HTML/CSS/DOM. + +--- + +### Task 1: GitHub filter fidelity + +**Files:** +- Modify: `src/core/patterns.ts` +- Modify: `src/core/filters.ts` +- Test: `test/patterns.test.ts` +- Test: `test/evaluate.test.ts` + +**Steps:** +1. Add failing tests proving `docs/**/*.md` matches both `docs/README.md` and deeper files. +2. Run `npx vitest run test/patterns.test.ts` and confirm the root-level case fails. +3. Implement a slash-aware globstar atom so `/**/` matches zero or more complete path segments without reintroducing backtracking. +4. Add failing evaluator tests proving tag pushes ignore path filters and known-empty changed-file sets skip both `paths` and `paths-ignore` workflows. +5. Run `npx vitest run test/evaluate.test.ts` and confirm both fail for the reviewed reasons. +6. Implement event and empty-diff preconditions in `evaluateTrigger`. +7. Run both test files and the ReDoS suite. +8. Commit as `fix: match GitHub trigger filter semantics`. + +### Task 2: Commit skip directives + +**Files:** +- Modify: `src/core/filters.ts` +- Modify: `src/core/types.ts` +- Test: `test/evaluate.test.ts` +- Test: `test/e2e-cli.test.ts` + +**Steps:** +1. Add failing tests for all five bracket directives and both `skip-checks` trailer forms. +2. Add a negative test proving incidental prose and `pull_request_target` do not skip. +3. Run targeted tests and confirm the ordinary push/PR cases incorrectly fire. +4. Add a precise commit-message parser and structured `commit-message-skip` reason. +5. Ensure PR specs can carry the HEAD commit message through CLI and web state. +6. Run targeted tests and commit as `fix: honor GitHub workflow skip directives`. + +### Task 3: Matrix and expression truthfulness + +**Files:** +- Modify: `src/core/evaluate.ts` +- Modify: `src/core/expr.ts` +- Modify: `src/core/types.ts` +- Test: `test/roast-fixes.test.ts` +- Test: `test/expr.test.ts` + +**Steps:** +1. Change the existing over-limit expectation to require an error job and zero firing variants; run it and confirm failure. +2. Return a structured `matrix-over-limit` job error before condition evaluation. +3. Add a failing filtered-array expression test for `labels.*.name`. +4. Implement `visitFilteredArray` using the actions expression data model while preserving unknown dependencies. +5. Run targeted tests, then commit as `fix: keep matrix and expression verdicts honest`. + +### Task 4: Git inference and invalid filter combinations + +**Files:** +- Modify: `src/cli/git.ts` +- Modify: `src/cli/index.ts` +- Modify: `src/core/filters.ts` +- Test: `test/git-inference.test.ts` +- Test: `test/e2e-cli.test.ts` +- Test: `test/evaluate.test.ts` + +**Steps:** +1. Add a failing git-inference test requiring a synchronized upstream to return a known empty outgoing set rather than last-commit files. +2. Implement the truthful result and a `no outgoing commits` source label. +3. Add failing tests requiring mutually exclusive include/ignore filter pairs to return an error instead of a guessed precedence rule. +4. Add structured invalid-filter-combination handling. +5. Run targeted tests and commit as `fix: stop fabricating push and invalid-filter outcomes`. + +### Task 5: Documentation and release truthfulness + +**Files:** +- Modify: `README.md` +- Modify: `docs/limitations.md` +- Modify: `CHANGELOG.md` +- Modify: `package.json` + +**Steps:** +1. Correct the 1,000-commits versus file-diff-limit explanation. +2. Document skip directives, globstar behavior, empty diffs, filtered arrays, matrix errors, and explicit no-pending-push behavior. +3. Remove the unsupported `paths`/`paths-ignore` precedence claim. +4. Bump the package to `0.1.1` so the PR can produce a source-identifiable release after merge. +5. Run `npm install --package-lock-only` and verify package/lock versions agree. +6. Commit as `docs: align fidelity and release contract`. + +### Task 6: Playground onboarding and action hierarchy + +**Files:** +- Modify: `web/index.html` +- Modify: `web/src/main.ts` +- Test: create `test/web-ui.test.ts` + +**Steps:** +1. Add DOM-level tests for a primary `Try the playground` action and a personal-workflow onboarding prompt. +2. Demote GitHub starring from the primary hero action while retaining it as a secondary trust link. +3. Make the primary action focus/scroll to the workflow editor. +4. Add concise “replace this example” and “keep exploring” guidance. +5. Run the web UI tests and commit as `feat: shorten the playground first-use path`. + +### Task 7: Resolution actions, mobile feedback, privacy, and motion + +**Files:** +- Modify: `web/index.html` +- Modify: `web/src/main.ts` +- Modify: `web/src/render.ts` +- Modify: `web/src/share.ts` +- Test: `test/web-ui.test.ts` + +**Steps:** +1. Add failing tests for copy-reason, switch-event, and view-updated-verdict actions. +2. Render contextual resolution controls from structured reason codes. +3. Add a mobile sticky verdict summary that links to the refreshed verdict panel. +4. Update share disclosure to state that the URL contains YAML and event values. +5. Animate only when verdict signatures change and disable nonessential motion under `prefers-reduced-motion`. +6. Run web tests and commit as `feat: close the diagnosis-to-resolution loop`. + +### Task 8: Browser, accessibility, and packaging verification + +**Files:** +- Modify only if verification exposes defects. + +**Steps:** +1. Run `npm run check` and `npm run build:web`. +2. Run `npm audit --json` and confirm zero current advisories or document exceptions. +3. Run `npm pack --dry-run --json` and confirm only intended package files ship. +4. Serve `web/dist` locally and inspect desktop and 390px mobile layouts. +5. Exercise keyboard navigation, hero CTA, editing, event switching, resolution actions, share copy, and reduced motion. +6. Check browser console and runtime network requests. +7. Run `git diff --check` and verify a clean worktree. + +### Task 9: Review and pull request + +**Files:** +- Review every changed file. + +**Steps:** +1. Use `superpowers:requesting-code-review` for an adversarial final diff review. +2. Resolve every validated finding and rerun the relevant verification. +3. Commit any final corrections. +4. Push `codex/review-remediation`. +5. Open a PR against `main` with problem, solution, fidelity reproductions, UX changes, and verification evidence. +6. Re-fetch the PR and confirm it is open, targets `main`, has the expected commits/files, and is not merged. diff --git a/package-lock.json b/package-lock.json index 7f55162..a69ed03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "actwhy", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "actwhy", - "version": "0.1.0", + "version": "0.1.1", "license": "MIT", "dependencies": { "@actions/expressions": "0.3.60", diff --git a/package.json b/package.json index 380e8c3..0c12e5a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "actwhy", - "version": "0.1.0", + "version": "0.1.1", "description": "Know which GitHub Actions workflows will fire \u2014 and exactly why the others won't \u2014 before you push.", "keywords": [ "github-actions", diff --git a/src/cli/git.ts b/src/cli/git.ts index 5ce9be8..c20d569 100644 --- a/src/cli/git.ts +++ b/src/cli/git.ts @@ -66,6 +66,8 @@ export interface ChangedFiles { files: string[]; /** Human label, e.g. "vs upstream origin/main" or "last commit only (no upstream)". */ source: string; + /** Whether the inferred upstream range contains a push event to simulate. */ + hasOutgoingCommits?: boolean; } /** @@ -83,11 +85,15 @@ export function changedFilesForPush(dir: string): ChangedFiles | null { const out = git(["diff", "--name-only", "@{push}..HEAD"], dir); if (out !== null) { const files = uniqLines(out); - // In-sync branch (0 outgoing commits): an empty set would produce a - // false "NOTHING fires". The question the user is asking is "what did - // my last push trigger?" — simulate the last commit instead. - if (files.length > 0) return { files, source: `vs upstream ${pushName}` }; - return lastCommitFiles(dir, `in sync with ${pushName} — simulating the last commit's push`) ?? { files, source: `vs upstream ${pushName}` }; + const hasOutgoingCommits = outgoingCommitStatus("@{push}", dir); + return { + files, + source: + hasOutgoingCommits === false + ? `no outgoing commits relative to ${pushName}` + : `vs upstream ${pushName}`, + ...(hasOutgoingCommits !== undefined ? { hasOutgoingCommits } : {}), + }; } } @@ -96,8 +102,15 @@ export function changedFilesForPush(dir: string): ChangedFiles | null { const out = git(["diff", "--name-only", "@{u}..HEAD"], dir); if (out !== null) { const files = uniqLines(out); - if (files.length > 0) return { files, source: `vs upstream ${upName}` }; - return lastCommitFiles(dir, `in sync with ${upName} — simulating the last commit's push`) ?? { files, source: `vs upstream ${upName}` }; + const hasOutgoingCommits = outgoingCommitStatus("@{u}", dir); + return { + files, + source: + hasOutgoingCommits === false + ? `no outgoing commits relative to ${upName}` + : `vs upstream ${upName}`, + ...(hasOutgoingCommits !== undefined ? { hasOutgoingCommits } : {}), + }; } } @@ -107,17 +120,30 @@ export function changedFilesForPush(dir: string): ChangedFiles | null { // Single-commit repo: HEAD has no parent, so show HEAD's own files. const out = git(["show", "--name-only", "--format=", "HEAD"], dir); - if (out !== null) return { files: uniqLines(out), source: "first commit (all files)" }; + if (out !== null) { + return { + files: uniqLines(out), + source: "first commit (all files)", + hasOutgoingCommits: true, + }; + } return null; } +/** Whether `..HEAD` contains commits, independent of its net file diff. */ +function outgoingCommitStatus(upstream: string, dir: string): boolean | undefined { + const raw = git(["rev-list", "--count", `${upstream}..HEAD`], dir); + if (raw === null || !/^\d+$/.test(raw)) return undefined; + return Number(raw) > 0; +} + function lastCommitFiles(dir: string, source: string): ChangedFiles | null { const hasParent = git(["rev-parse", "--verify", "-q", "HEAD~1"], dir); if (hasParent === null) return null; const out = git(["diff", "--name-only", "HEAD~1..HEAD"], dir); if (out === null) return null; - return { files: uniqLines(out), source }; + return { files: uniqLines(out), source, hasOutgoingCommits: true }; } /** Result of inferring a PR's changed files; `files` is null when it can't be diffed. */ diff --git a/src/cli/index.ts b/src/cli/index.ts index 5fcf9fb..f5c81e1 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -38,13 +38,13 @@ const SHARED: OptConfig = { "no-color": { type: "boolean" }, help: { type: "boolean", short: "h" }, version: { type: "boolean", short: "V" }, + "commit-message": { type: "string", short: "m" }, }; const PUSH_OPTS: OptConfig = { ...SHARED, branch: { type: "string", short: "b" }, tag: { type: "string" }, - "commit-message": { type: "string", short: "m" }, }; const PR_OPTS: OptConfig = { @@ -229,6 +229,7 @@ async function run(): Promise { } let pushFiles: string[] | null; + let hasOutgoingCommits: boolean | undefined; if (filesFlag !== undefined) { pushFiles = filesFlag; filesSource = "from --files"; @@ -237,6 +238,9 @@ async function run(): Promise { if (inferred) { pushFiles = inferred.files; filesSource = inferred.source; + if (branchFlag === undefined && tagFlag === undefined) { + hasOutgoingCommits = inferred.hasOutgoingCommits; + } } else { pushFiles = null; notes.push("could not infer changed files from git — paths filters will be UNKNOWN (pass --files)"); @@ -250,6 +254,7 @@ async function run(): Promise { ...(branch !== undefined ? { branch } : {}), ...(tag !== undefined ? { tag } : {}), files: pushFiles, + ...(hasOutgoingCommits !== undefined ? { hasOutgoingCommits } : {}), ...(commitMessage !== undefined ? { commitMessage } : {}), ...(repository !== undefined ? { repository } : {}), ...(defaultBranch !== undefined ? { defaultBranch } : {}), @@ -270,6 +275,7 @@ async function run(): Promise { const head = getStr("head") ?? git.currentBranch(root) ?? undefined; const activityType = getStr("type") ?? "opened"; const event = getBool("target") ? "pull_request_target" : "pull_request"; + const commitMessage = getStr("commit-message") ?? git.headCommitMessage(root) ?? undefined; let prFiles: string[] | null; if (filesFlag !== undefined) { @@ -289,6 +295,7 @@ async function run(): Promise { ...(head !== undefined ? { head } : {}), files: prFiles, activityType, + ...(commitMessage !== undefined ? { commitMessage } : {}), ...(getBool("draft") ? { draft: true } : {}), ...(repository !== undefined ? { repository } : {}), ...(defaultBranch !== undefined ? { defaultBranch } : {}), @@ -368,6 +375,7 @@ push OPTIONS pr OPTIONS --base target branch (default: git default branch, else "main") --head source branch (default: current git branch) + -m, --commit-message HEAD commit message to simulate (default: HEAD message) --type activity type: opened, synchronize, reopened … (default: opened) --draft simulate a draft pull request --target simulate pull_request_target instead of pull_request diff --git a/src/cli/render.ts b/src/cli/render.ts index 9021a67..749a4c8 100644 --- a/src/cli/render.ts +++ b/src/cli/render.ts @@ -162,7 +162,8 @@ function header(event: EventSpec, opts: RenderOptions, c: Colors): string { if (filesCount === null) { filesDesc = "changed files unknown"; } else if (filesCount === 0) { - filesDesc = "0 changed files"; + const src = opts.filesSource ? ` (${opts.filesSource})` : ""; + filesDesc = `0 changed files${src}`; } else { const src = opts.filesSource ? ` (${opts.filesSource})` : ""; filesDesc = `${filesCount} changed file${filesCount === 1 ? "" : "s"}${src}`; diff --git a/src/core/evaluate.ts b/src/core/evaluate.ts index 555dbfa..a375ae4 100644 --- a/src/core/evaluate.ts +++ b/src/core/evaluate.ts @@ -299,10 +299,16 @@ function evaluateJob( base.matrix = matrix.count; base.matrixNote = matrix.note; if (matrix.overLimit) { - warnings.push({ - code: "matrix-over-limit", - message: `job "${job.id.value}": ${matrix.note}`, - }); + return { + ...base, + verdict: "error", + reasons: [ + { + code: "matrix-over-limit", + message: `job "${job.id.value}" ${matrix.note ?? "exceeds GitHub's 256-job matrix limit"}`, + }, + ], + }; } } diff --git a/src/core/expr.ts b/src/core/expr.ts index 14d8715..6614ada 100644 --- a/src/core/expr.ts +++ b/src/core/expr.ts @@ -22,6 +22,7 @@ import { IndexAccess, Literal, Logical, + Star, Unary, } from "@actions/expressions/ast"; import * as data from "@actions/expressions/data/index"; @@ -282,6 +283,7 @@ export class TriEvaluator implements ExprVisitor { if (expr instanceof IndexAccess) { const base = this.staticPath(expr.expr); if (!base) return null; + if (expr.index instanceof Star) return [...base, "*"]; if (expr.index instanceof Literal) { const lit = expr.index.literal; if (lit.kind === data.Kind.String) return [...base, (lit as data.StringData).value]; @@ -293,6 +295,8 @@ export class TriEvaluator implements ExprVisitor { } private resolvePath(path: string[]): TriValue { + if (path.includes("*")) return this.resolveFilteredPath(path); + const dotted = path.join("."); let node: CtxValue | undefined = this.context[path[0]]; if (node === undefined) return known(new data.Null()); @@ -337,6 +341,78 @@ export class TriEvaluator implements ExprVisitor { return known(d); } + /** + * Resolve GitHub's object-filter syntax (`labels.*.name`) without calling + * Star.accept(), which the upstream AST intentionally leaves unimplemented. + */ + private resolveFilteredPath(path: string[]): TriValue { + const dotted = path.join("."); + let nodes: CtxValue[] = [this.context[path[0]] ?? null]; + let filtering = false; + + for (let i = 1; i < path.length; i++) { + const key = path[i]; + const next: CtxValue[] = []; + + for (const node of nodes) { + if (node instanceof Unk) { + return unknown("unknown", [ + { path: dotted, why: node.why, hint: node.hint }, + ]); + } + + if (key === "*") { + filtering = true; + if (node instanceof PartialDict) { + return unknown("unknown", [ + { path: dotted, why: node.why, hint: node.hint }, + ]); + } + if (Array.isArray(node)) { + next.push(...node); + } else if (node !== null && typeof node === "object") { + next.push(...Object.values(node as Record)); + } + continue; + } + + if (node instanceof PartialDict) { + if (key in node.entries) { + next.push(node.entries[key]); + } else { + return unknown("unknown", [ + { path: dotted, why: node.why, hint: node.hint }, + ]); + } + continue; + } + if (node !== null && typeof node === "object" && !Array.isArray(node)) { + const record = node as Record; + if (key in record) next.push(record[key]); + else if (!filtering) next.push(null); + continue; + } + if (Array.isArray(node)) { + const index = Number(key); + if (Number.isInteger(index) && index >= 0 && index < node.length) { + next.push(node[index]); + } else if (!filtering) { + next.push(null); + } + continue; + } + if (!filtering) next.push(null); + } + nodes = next; + } + + const value = toData(nodes); + if (value instanceof Unk || value instanceof PartialDict) { + return unknown("unknown", [{ path: dotted, why: value.why }]); + } + return known(value); + } + private indexInto(base: data.ExpressionData, index: data.ExpressionData): TriValue { if (base.kind === data.Kind.Dictionary || (base.kind as data.Kind) === data.Kind.CaseSensitiveDictionary) { const dict = base as data.Dictionary; diff --git a/src/core/filters.ts b/src/core/filters.ts index 2060b2d..f57d3bb 100644 --- a/src/core/filters.ts +++ b/src/core/filters.ts @@ -24,6 +24,11 @@ const unknown = (reasons: Reason[], warnings: Reason[] = []): TriggerResult => ( reasons, warnings, }); +const error = (reasons: Reason[]): TriggerResult => ({ + verdict: "error", + reasons, + warnings: [], +}); const DEFAULT_PR_TYPES = ["opened", "synchronize", "reopened"]; @@ -55,6 +60,24 @@ export function evaluateTrigger(events: EventsConfig, spec: EventSpec): TriggerR const paths = push.paths; const pathsIgnore = push["paths-ignore"]; + const invalidPair = mutuallyExclusiveFilterPair([ + ["branches", branches, "branches-ignore", branchesIgnore], + ["tags", tags, "tags-ignore", tagsIgnore], + ["paths", paths, "paths-ignore", pathsIgnore], + ]); + if (invalidPair) return error([invalidPair]); + if (spec.hasOutgoingCommits === false) { + return skip([ + { + code: "no-outgoing-commits", + message: "the current branch has no outgoing commits, so no push event will occur", + }, + ]); + } + if (hasCommitSkipDirective(spec.commitMessage)) { + return skip([commitSkipReason()]); + } + const hasBranchFilters = branches !== undefined || branchesIgnore !== undefined; const hasTagFilters = tags !== undefined || tagsIgnore !== undefined; @@ -83,6 +106,9 @@ export function evaluateTrigger(events: EventsConfig, spec: EventSpec): TriggerR : checkRefFilters("branch", refName, branches, branchesIgnore); if (refReason) return skip([refReason]); + // GitHub does not evaluate path filters for tag pushes. + if (isTag) return fires(warnings); + const pathResult = checkPathFilters(paths, pathsIgnore, spec.files, warnings); if (pathResult) return pathResult; @@ -97,6 +123,14 @@ export function evaluateTrigger(events: EventsConfig, spec: EventSpec): TriggerR if (pr === undefined) { return skip([noListenerReason(eventName, listeners)]); } + const invalidPair = mutuallyExclusiveFilterPair([ + ["branches", pr.branches, "branches-ignore", pr["branches-ignore"]], + ["paths", pr.paths, "paths-ignore", pr["paths-ignore"]], + ]); + if (invalidPair) return error([invalidPair]); + if (eventName === "pull_request" && hasCommitSkipDirective(spec.commitMessage)) { + return skip([commitSkipReason()]); + } const types = pr.types ?? DEFAULT_PR_TYPES; const activity = spec.activityType ?? "opened"; @@ -120,6 +154,43 @@ export function evaluateTrigger(events: EventsConfig, spec: EventSpec): TriggerR return fires(warnings); } +function mutuallyExclusiveFilterPair( + pairs: Array< + [ + includeName: string, + include: readonly string[] | undefined, + ignoreName: string, + ignore: readonly string[] | undefined, + ] + >, +): Reason | undefined { + for (const [includeName, include, ignoreName, ignore] of pairs) { + if (include !== undefined && ignore !== undefined) { + return { + code: "invalid-filter-combination", + message: `GitHub does not allow \`${includeName}\` and \`${ignoreName}\` on the same event`, + }; + } + } + return undefined; +} + +function hasCommitSkipDirective(message: string | undefined): boolean { + if (!message) return false; + const normalized = message.replace(/\r\n?/g, "\n"); + if (/\[(?:skip ci|ci skip|no ci|skip actions|actions skip)\]/i.test(normalized)) { + return true; + } + return /(?:^|\n\n)skip-checks:\s*true\s*$/i.test(normalized); +} + +function commitSkipReason(): Reason { + return { + code: "commit-message-skip", + message: "the commit message contains a GitHub Actions skip directive", + }; +} + function noListenerReason(eventName: string, listeners: string[]): Reason { const others = listeners.length > 0 ? listeners.join(", ") : "nothing"; let hint: string | undefined; @@ -186,22 +257,12 @@ function checkPathFilters( } if (files.length === 0) { - warnings.push({ - code: "no-changed-files", - message: - "no changed files detected — paths filters evaluated against an empty set", - }); - } - - // GitHub documents `paths` and `paths-ignore` as mutually exclusive. If a - // workflow sets both, GitHub uses `paths` and ignores `paths-ignore`; warn - // so the author knows half their intent is being dropped (by GitHub, not us). - if (paths !== undefined && pathsIgnore !== undefined) { - warnings.push({ - code: "paths-and-paths-ignore", - message: - "both paths and paths-ignore are set — GitHub uses paths and ignores paths-ignore", - }); + return skip([ + { + code: "no-changed-files", + message: "no changed files — GitHub does not run path-filtered workflows for an empty diff", + }, + ]); } if (paths !== undefined) { diff --git a/src/core/patterns.ts b/src/core/patterns.ts index 6c9ba2d..91501ed 100644 --- a/src/core/patterns.ts +++ b/src/core/patterns.ts @@ -28,6 +28,12 @@ interface Atom { /** Predicate for one character. */ test: (c: string) => boolean; quant: Quant; + /** + * A slash-delimited globstar is zero or more complete path segments, + * including the separating slash. Keeping that construct explicit avoids + * making the slash mandatory when it matches zero directories. + */ + directoryGlobstar?: boolean; } export interface CompiledPattern { @@ -96,11 +102,19 @@ function parseAtoms(pattern: string, original: string): Atom[] { while (i < pattern.length) { const c = pattern[i]; if (c === "*") { + const starStart = i; let stars = 0; while (pattern[i] === "*") { stars++; i++; } + const startsPathSegment = + starStart === 0 || pattern[starStart - 1] === "/"; + if (stars >= 2 && startsPathSegment && pattern[i] === "/") { + atoms.push({ test: ANY, quant: "*", directoryGlobstar: true }); + i++; + continue; + } push(stars >= 2 ? ANY : NOT_SLASH, "*"); continue; } @@ -140,7 +154,7 @@ function parseAtoms(pattern: string, original: string): Atom[] { /** True when the atom can match the empty string. */ function canBeEmpty(a: Atom): boolean { - return a.quant === "?" || a.quant === "*"; + return a.directoryGlobstar === true || a.quant === "?" || a.quant === "*"; } /** @@ -151,13 +165,22 @@ function canBeEmpty(a: Atom): boolean { function matchAtoms(atoms: Atom[], value: string): boolean { const n = atoms.length; let state = new Array(n + 1).fill(false); + let partial = new Array(n).fill(false); state[0] = true; for (let j = 0; j < n; j++) state[j + 1] = state[j] && canBeEmpty(atoms[j]); for (const c of value) { const next = new Array(n + 1).fill(false); + const nextPartial = new Array(n).fill(false); for (let j = 0; j < n; j++) { const a = atoms[j]; + if (a.directoryGlobstar) { + if (state[j] || partial[j]) { + nextPartial[j] = true; + if (c === "/") next[j + 1] = true; + } + continue; + } if (!a.test(c)) continue; // Enter atom j from "before it"… if (state[j]) next[j + 1] = true; @@ -168,7 +191,8 @@ function matchAtoms(atoms: Atom[], value: string): boolean { if (next[j] && canBeEmpty(atoms[j])) next[j + 1] = true; } state = next; - if (!state.includes(true)) return false; + partial = nextPartial; + if (!state.includes(true) && !partial.includes(true)) return false; } return state[n]; } diff --git a/src/core/types.ts b/src/core/types.ts index 29a8b16..514f276 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -16,6 +16,11 @@ export interface PushSpec { * yield UNKNOWN verdicts instead of guessing. */ files: string[] | null; + /** + * False only when zero-config git inference proves the current branch has + * no commits to push. Omitted for explicit simulations. + */ + hasOutgoingCommits?: boolean; commitMessage?: string; /** "owner/repo", used for the github.repository context when known. */ repository?: string; @@ -35,6 +40,8 @@ export interface PrSpec { /** Head (source) branch. */ head?: string; files: string[] | null; + /** HEAD commit message, used for GitHub's pull-request skip directives. */ + commitMessage?: string; /** Activity type being simulated. Default: "opened". */ activityType?: string; draft?: boolean; diff --git a/test/e2e-cli.test.ts b/test/e2e-cli.test.ts index dd81d49..67d824a 100644 --- a/test/e2e-cli.test.ts +++ b/test/e2e-cli.test.ts @@ -16,6 +16,9 @@ on: push: branches: [main] paths: ['src/**'] + pull_request: + branches: [main] + paths: ['src/**'] jobs: build: runs-on: ubuntu-latest @@ -123,6 +126,26 @@ describe("actwhy CLI end-to-end", () => { expect(report.closestMiss?.file).toBe("ci.yml"); }, 30_000); + it("`pr -m` simulates a pull-request commit skip directive", async () => { + const { code, stdout } = await runCli([ + "pr", + "-C", + fixtureDir, + "--base", + "main", + "--files", + "src/a.ts", + "-m", + "test: update [skip ci]", + "--json", + ]); + expect(code).toBe(0); + + const report = JSON.parse(stdout) as Report; + expect(report.workflows[0].verdict).toBe("skipped"); + expect(report.workflows[0].reasons[0].code).toBe("commit-message-skip"); + }, 30_000); + it("an unknown flag exits 2 (usage error) with a help pointer on stderr", async () => { const { code, stderr } = await runCli(["--nope"]); expect(code).toBe(2); diff --git a/test/evaluate.test.ts b/test/evaluate.test.ts index 06082e8..2fbf677 100644 --- a/test/evaluate.test.ts +++ b/test/evaluate.test.ts @@ -192,6 +192,135 @@ jobs: const w = wf(await evaluateWorkflows(files, spec), "pi.yml"); expect(w.verdict).toBe("fires"); }); + + it("skips a path-filtered workflow when the known changed-file set is empty", async () => { + const spec: PushSpec = { kind: "push", branch: "main", files: [] }; + const w = wf(await evaluateWorkflows(files, spec), "pi.yml"); + expect(w.verdict).toBe("skipped"); + expect(codes(w.reasons)).toContain("no-changed-files"); + }); +}); + +describe("push path-filter edge cases", () => { + it("ignores paths filters for tag pushes", async () => { + const files = [ + file( + "tag.yml", + ` +on: + push: + tags: ['v*'] + paths: ['src/**'] +jobs: + j: {runs-on: ubuntu-latest, steps: [{run: echo}]} +`, + ), + ]; + const spec: PushSpec = { + kind: "push", + tag: "v1.0.0", + files: ["docs/release.md"], + }; + const w = wf(await evaluateWorkflows(files, spec), "tag.yml"); + expect(w.verdict).toBe("fires"); + }); + + it("skips a paths workflow when the known changed-file set is empty", async () => { + const files = [ + file( + "paths.yml", + ` +on: + push: + paths: ['src/**'] +jobs: + j: {runs-on: ubuntu-latest, steps: [{run: echo}]} +`, + ), + ]; + const spec: PushSpec = { kind: "push", branch: "main", files: [] }; + const w = wf(await evaluateWorkflows(files, spec), "paths.yml"); + expect(w.verdict).toBe("skipped"); + expect(codes(w.reasons)).toContain("no-changed-files"); + }); +}); + +describe("commit-message skip directives", () => { + const files = [ + file( + "skip.yml", + ` +on: [push, pull_request, pull_request_target] +jobs: + j: {runs-on: ubuntu-latest, steps: [{run: echo}]} +`, + ), + ]; + + it.each([ + "[skip ci]", + "[ci skip]", + "[no ci]", + "[skip actions]", + "[actions skip]", + "[SKIP CI]", + ])("skips push workflows for %s", async (directive) => { + const spec: PushSpec = { + kind: "push", + branch: "main", + files: ["src/a.ts"], + commitMessage: `chore: save time ${directive}`, + }; + const w = wf(await evaluateWorkflows(files, spec), "skip.yml"); + expect(w.verdict).toBe("skipped"); + expect(codes(w.reasons)).toContain("commit-message-skip"); + }); + + it.each(["skip-checks:true", "skip-checks: true"])( + "skips when the final commit trailer is %s", + async (trailer) => { + const spec: PushSpec = { + kind: "push", + branch: "main", + files: ["src/a.ts"], + commitMessage: `chore: save time\n\n${trailer}`, + }; + const w = wf(await evaluateWorkflows(files, spec), "skip.yml"); + expect(w.verdict).toBe("skipped"); + expect(codes(w.reasons)).toContain("commit-message-skip"); + }, + ); + + it("does not mistake incidental prose for a skip directive", async () => { + const spec: PushSpec = { + kind: "push", + branch: "main", + files: ["src/a.ts"], + commitMessage: "docs: explain skip ci and skip-checks: true\n\nnot a trailer", + }; + expect(wf(await evaluateWorkflows(files, spec), "skip.yml").verdict).toBe("fires"); + }); + + it("applies to pull_request but not pull_request_target", async () => { + const base: PrSpec = { + kind: "pull_request", + base: "main", + files: ["src/a.ts"], + commitMessage: "test: update [skip ci]", + }; + const pr = wf(await evaluateWorkflows(files, base), "skip.yml"); + expect(pr.verdict).toBe("skipped"); + expect(codes(pr.reasons)).toContain("commit-message-skip"); + + const target = wf( + await evaluateWorkflows( + files, + { ...base, event: "pull_request_target" }, + ), + "skip.yml", + ); + expect(target.verdict).toBe("fires"); + }); }); // ── (c) workflow_dispatch only ─────────────────────────────────────────── diff --git a/test/expr.test.ts b/test/expr.test.ts index cdd6277..2592ad0 100644 --- a/test/expr.test.ts +++ b/test/expr.test.ts @@ -106,6 +106,26 @@ describe("string functions", () => { it("fromJSON('true') is truthy", () => { expect(evaluateIf("fromJSON('true')", ctx, OK).truthiness).toBe(true); }); + + it("projects filtered arrays such as pull-request label names", () => { + const pr = buildRootContext({ + kind: "pull_request", + base: "main", + files: ["src/a.ts"], + payload: { + pull_request: { + labels: [{ name: "bug" }, { name: "urgent" }], + }, + }, + }); + const out = evaluateIf( + "contains(github.event.pull_request.labels.*.name, 'bug')", + pr, + OK, + ); + expect(out.error).toBeUndefined(); + expect(out.truthiness).toBe(true); + }); }); describe("status functions", () => { diff --git a/test/git-inference.test.ts b/test/git-inference.test.ts index 8382195..f7db440 100644 --- a/test/git-inference.test.ts +++ b/test/git-inference.test.ts @@ -4,12 +4,9 @@ * (a) When local `main` is AHEAD of `origin/main`, the CLI must simulate the * OUTGOING commit's files — and the human header count must agree with the * JSON `event.files`. - * (b) When the branch is IN SYNC (0 outgoing), the DOCUMENTED post-fix - * behavior is to fall back to the LAST commit's files (HEAD~1..HEAD) so a - * paths-filtered workflow still fires instead of spuriously reporting - * "nothing fires" from an empty file set. If that fallback isn't in the - * build yet (a concurrent fix), scenario (b) skips itself at runtime - * rather than asserting the current buggy empty-set behavior. + * (b) When the branch is IN SYNC (0 outgoing), the CLI must report a known + * empty outgoing set. It must not quietly substitute the last commit and + * claim that a future push contains already-pushed files. * * The CLI is bundled to a PRIVATE temp path (see e2e-event) to avoid a * parallel-worker race on the shared dist/actwhy.js. Git setup is hermetic: @@ -39,6 +36,14 @@ jobs: steps: [{run: echo build}] `; +const UNFILTERED_YAML = `name: Unfiltered +on: push +jobs: + build: + runs-on: ubuntu-latest + steps: [{run: echo build}] +`; + // c1's changed-file set (both the outgoing diff in (a) and the last-commit // fallback in (b)). src/app.ts makes the paths:['src/**'] filter match. const C1_FILES = ["CHANGELOG.md", "src/app.ts"]; @@ -53,6 +58,8 @@ let binPath: string; let workDir: string; let repoAhead: string; let repoInSync: string; +let repoEmptyAhead: string; +let repoBehind: string; function runCli(args: string[], cwd: string): Promise { return new Promise((resolve) => { @@ -94,6 +101,10 @@ function makeRepo(name: string, pushC1: boolean): string { // c0: base file + workflow writeFileSync(join(repo, "README.md"), "# base\n"); writeFileSync(join(repo, ".github", "workflows", "ci.yml"), CI_YAML); + writeFileSync( + join(repo, ".github", "workflows", "unfiltered.yml"), + UNFILTERED_YAML, + ); g(["add", "-A"], repo); g(["commit", "--no-verify", "-m", "c0: base"], repo); g(["remote", "add", "origin", bare], repo); @@ -133,6 +144,11 @@ beforeAll(async () => { repoAhead = makeRepo("ahead", false); repoInSync = makeRepo("insync", true); + repoEmptyAhead = makeRepo("empty-ahead", true); + g(["commit", "--allow-empty", "--no-verify", "-m", "c2: empty outgoing commit"], repoEmptyAhead); + repoBehind = makeRepo("behind", true); + g(["checkout", "-b", "behind", "HEAD~1"], repoBehind); + g(["branch", "--set-upstream-to", "origin/main", "behind"], repoBehind); }, 120_000); afterAll(() => { @@ -149,8 +165,8 @@ describe("git changed-file inference for push", () => { const jsonFiles = (report.event as { files: string[] | null }).files ?? []; expect([...jsonFiles].sort()).toEqual([...C1_FILES].sort()); - // the outgoing paths-matching file makes the workflow fire - expect(report.workflows[0].verdict).toBe("fires"); + // the outgoing paths-matching file makes both workflows fire + expect(report.workflows.every((workflow) => workflow.verdict === "fires")).toBe(true); expect(report.nothingFires).toBe(false); // human header: " changed files (vs upstream …)" — n must equal JSON's count @@ -161,23 +177,66 @@ describe("git changed-file inference for push", () => { expect(Number(m![1])).toBe(jsonFiles.length); }, 30_000); - it("(b) IN SYNC (0 outgoing) → falls back to the last commit's files (post-fix behavior)", async (ctx) => { + it("(b) IN SYNC (0 outgoing) → reports an explicit empty outgoing set", async () => { const json = await runCli(["push", "-C", repoInSync, "--json"], repoInSync); expect(json.code).toBe(0); const report = JSON.parse(json.stdout) as Report; - const files = (report.event as { files: string[] | null }).files ?? []; - - if (!Array.isArray(files) || files.length === 0) { - // Fallback not present in this build (concurrent fix pending): the - // in-sync diff is empty. Skip rather than asserting the buggy empty set. - // TODO(git.ts): when @{push}..HEAD is empty, fall back to HEAD~1..HEAD. - ctx.skip(); - return; - } - - // Post-fix: last commit's files, and NOT a spurious nothing-fires. - expect([...files].sort()).toEqual([...C1_FILES].sort()); + const event = report.event as { + files: string[] | null; + hasOutgoingCommits?: boolean; + }; + const files = event.files ?? []; + + expect(files).toEqual([]); + expect(event.hasOutgoingCommits).toBe(false); + expect(report.nothingFires).toBe(true); + expect(report.workflows.every((workflow) => workflow.verdict === "skipped")).toBe(true); + expect( + report.workflows.every((workflow) => + workflow.reasons.some((reason) => reason.code === "no-outgoing-commits"), + ), + ).toBe(true); + + const human = await runCli(["push", "-C", repoInSync], repoInSync); + expect(human.stdout).toContain("no outgoing commits"); + }, 30_000); + + it("(c) empty commit AHEAD → simulates a real push even though no files changed", async () => { + const json = await runCli(["push", "-C", repoEmptyAhead, "--json"], repoEmptyAhead); + expect(json.code).toBe(0); + const report = JSON.parse(json.stdout) as Report; + const event = report.event as { + files: string[] | null; + hasOutgoingCommits?: boolean; + }; + + expect(event.files).toEqual([]); + expect(event.hasOutgoingCommits).toBe(true); expect(report.nothingFires).toBe(false); - expect(report.workflows[0].verdict).toBe("fires"); + expect( + report.workflows.find((workflow) => workflow.file === "unfiltered.yml")?.verdict, + ).toBe("fires"); + expect( + report.workflows.find((workflow) => workflow.file === "ci.yml")?.reasons[0]?.code, + ).toBe("no-changed-files"); + }, 30_000); + + it("(d) branch only BEHIND upstream → does not mistake the tree diff for an outgoing push", async () => { + const json = await runCli(["push", "-C", repoBehind, "--json"], repoBehind); + expect(json.code).toBe(0); + const report = JSON.parse(json.stdout) as Report; + const event = report.event as { + files: string[] | null; + hasOutgoingCommits?: boolean; + }; + + expect(event.files?.length).toBeGreaterThan(0); + expect(event.hasOutgoingCommits).toBe(false); + expect(report.nothingFires).toBe(true); + expect( + report.workflows.every((workflow) => + workflow.reasons.some((reason) => reason.code === "no-outgoing-commits"), + ), + ).toBe(true); }, 30_000); }); diff --git a/test/patterns.test.ts b/test/patterns.test.ts index c3100cc..ce806d2 100644 --- a/test/patterns.test.ts +++ b/test/patterns.test.ts @@ -37,6 +37,20 @@ describe("compilePattern — `**` (crosses `/`)", () => { expect(matches("**", "main")).toBe(true); expect(matches("**", "")).toBe(true); }); + + it("a slash-delimited globstar matches zero or more directories", () => { + expect(matches("docs/**/*.md", "docs/README.md")).toBe(true); + expect(matches("docs/**/*.md", "docs/guides/start.md")).toBe(true); + expect(matches("a/**/b", "a/b")).toBe(true); + expect(matches("a/**/b", "a/x/y/b")).toBe(true); + expect(matches("a/**/b", "a/xb")).toBe(false); + }); + + it("does not absorb the slash after a non-delimited globstar", () => { + expect(matches("foo**/bar", "foobar")).toBe(false); + expect(matches("foo**/bar", "foo/bar")).toBe(true); + expect(matches("foo**/bar", "foo/x/bar")).toBe(true); + }); }); describe("compilePattern — `?` is ZERO OR ONE of the preceding char (regex-style)", () => { diff --git a/test/roast-fixes.test.ts b/test/roast-fixes.test.ts index 1daf1b1..d09136d 100644 --- a/test/roast-fixes.test.ts +++ b/test/roast-fixes.test.ts @@ -49,20 +49,21 @@ function matrixWorkflow(a: number, b: number): string { // ── Matrix 256 hard cap ───────────────────────────────────────────────────── -describe("matrix over-limit warning (GitHub's 256-job cap)", () => { - it("flags a matrix that expands to >256 static combos and still reports the job", async () => { +describe("matrix over-limit error (GitHub's 256-job cap)", () => { + it("marks a matrix that expands to >256 static combos as an error", async () => { // 17 x 16 = 272 > 256. const report = await evaluateWorkflows([file("m.yml", matrixWorkflow(17, 16))], mainPush); const w = report.workflows[0]; expect(w.verdict).toBe("fires"); - const over = w.warnings.find((r) => r.code === "matrix-over-limit"); - expect(over, "expected a matrix-over-limit warning").toBeDefined(); - - // The job is still present and reported, with the full (over-limit) count. + // The job is retained for diagnosis, but it cannot truthfully be counted + // as firing because GitHub rejects the expansion. expect(w.jobs).toHaveLength(1); - expect(w.jobs[0].verdict).toBe("fires"); + expect(w.jobs[0].verdict).toBe("error"); + expect(w.jobs[0].reasons.map((r) => r.code)).toContain("matrix-over-limit"); expect(w.jobs[0].matrix).toBe(272); + expect(report.summary.jobsFiring).toBe(0); + expect(report.summary.matrixVariantsFiring).toBe(0); }); it("does NOT warn when the matrix is exactly 256 (at the cap, not over it)", async () => { @@ -70,14 +71,14 @@ describe("matrix over-limit warning (GitHub's 256-job cap)", () => { const report = await evaluateWorkflows([file("m.yml", matrixWorkflow(16, 16))], mainPush); const w = report.workflows[0]; expect(w.jobs[0].matrix).toBe(256); - expect(w.warnings.some((r) => r.code === "matrix-over-limit")).toBe(false); + expect(w.jobs[0].verdict).toBe("fires"); }); }); // ── paths + paths-ignore both set ─────────────────────────────────────────── describe("paths and paths-ignore both set on one event", () => { - it("warns and follows `paths` (paths-ignore dropped, matching GitHub)", async () => { + it("reports an error instead of guessing which mutually exclusive filter wins", async () => { const content = [ "on:", " push:", @@ -88,14 +89,34 @@ describe("paths and paths-ignore both set on one event", () => { " b: {runs-on: ubuntu-latest, steps: [{run: echo}]}", "", ].join("\n"); - // src/app.ts matches `paths`; if paths-ignore were applied, all files would - // be ignored and the workflow would skip. It fires -> paths won. const spec: PushSpec = { kind: "push", branch: "main", files: ["src/app.ts"] }; const report = await evaluateWorkflows([file("p.yml", content)], spec); const w = report.workflows[0]; - expect(w.verdict).toBe("fires"); - expect(w.warnings.some((r) => r.code === "paths-and-paths-ignore")).toBe(true); + expect(w.verdict).toBe("error"); + expect(w.reasons.map((r) => r.code)).toContain("invalid-filter-combination"); + }); + + it.each([ + ["branches", "branches-ignore", "branch"], + ["tags", "tags-ignore", "tag"], + ])("rejects %s together with %s", async (include, ignore, refKind) => { + const content = [ + "on:", + " push:", + ` ${include}: [main]`, + ` ${ignore}: [legacy]`, + "jobs:", + " b: {runs-on: ubuntu-latest, steps: [{run: echo}]}", + "", + ].join("\n"); + const spec: PushSpec = + refKind === "tag" + ? { kind: "push", tag: "main", files: ["src/app.ts"] } + : { kind: "push", branch: "main", files: ["src/app.ts"] }; + const w = (await evaluateWorkflows([file("p.yml", content)], spec)).workflows[0]; + expect(w.verdict).toBe("error"); + expect(w.reasons.map((r) => r.code)).toContain("invalid-filter-combination"); }); }); diff --git a/test/web-engine.test.ts b/test/web-engine.test.ts new file mode 100644 index 0000000..3a8601b --- /dev/null +++ b/test/web-engine.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { evaluateWorkflows } from "../src/core/index.js"; +import { buildSpec } from "../web/src/engine.js"; +import type { AppState } from "../web/src/engine.js"; +import { resolutionActions } from "../web/src/actions.js"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); + +const state = (overrides: Partial = {}): AppState => ({ + files: [{ name: "ci.yml", content: "on: push\njobs: {}" }], + active: 0, + event: "push", + branch: "main", + tag: "v1.0.0", + changed: "src/a.ts", + commitMessage: "test: update [skip ci]", + prBase: "main", + prHead: "feature/test", + prActivity: "opened", + ...overrides, +}); + +describe("web buildSpec", () => { + it("passes the commit message into pull request simulations", () => { + const spec = buildSpec(state({ event: "pull_request" })); + expect(spec.kind).toBe("pull_request"); + if (spec.kind === "pull_request") { + expect(spec.commitMessage).toBe("test: update [skip ci]"); + } + }); +}); + +describe("website onboarding and privacy contract", () => { + const html = readFileSync(join(root, "web", "index.html"), "utf8"); + + it("makes the live playground the primary hero action", () => { + expect(html).toContain('class="hero-primary" href="#playground"'); + expect(html).toContain("Try the live playground"); + }); + + it("prompts visitors to replace the example with their own workflow", () => { + expect(html).toContain("Replace this example with your workflow"); + expect(html).toContain('id="focus-editor"'); + }); + + it("warns that shared URLs contain scenario data and can be retained", () => { + expect(html).toContain("workflow YAML"); + expect(html).toContain("browser history"); + expect(html).toContain("chat"); + }); + + it("includes a mobile updated-verdict control and reduced-motion fallback", () => { + expect(html).toContain('id="mobile-verdict"'); + expect(html).toContain("View updated verdict"); + expect(html).toContain("@media (prefers-reduced-motion: reduce)"); + }); +}); + +describe("contextual resolution actions", () => { + it("offers a branch retest and reason copy for a branch-filter miss", async () => { + const report = await evaluateWorkflows( + [ + { + name: "ci.yml", + content: "on:\n push:\n branches: [main]\njobs:\n j: {runs-on: ubuntu-latest}\n", + }, + ], + { kind: "push", branch: "feature/x", files: ["src/a.ts"] }, + ); + const actions = resolutionActions(report); + expect(actions.map((a) => a.id)).toEqual([ + "test-branch", + "switch-pr", + "copy-reason", + "share", + ]); + }); + + it("offers changed-path input for a path-filter miss", async () => { + const report = await evaluateWorkflows( + [ + { + name: "ci.yml", + content: "on:\n push:\n paths: ['src/**']\njobs:\n j: {runs-on: ubuntu-latest}\n", + }, + ], + { kind: "push", branch: "main", files: ["docs/readme.md"] }, + ); + expect(resolutionActions(report).map((a) => a.id)).toContain("add-path"); + }); +}); diff --git a/web/index.html b/web/index.html index a4582c5..a66b8bb 100644 --- a/web/index.html +++ b/web/index.html @@ -32,7 +32,7 @@ "name": "actwhy", "applicationCategory": "DeveloperApplication", "operatingSystem": "Any", - "description": "Know which GitHub Actions workflows will fire — and exactly why the others won't — before you push. A three-valued (fires / skipped / unknown) trigger explainer built on GitHub's own workflow parser.", + "description": "Know which GitHub Actions workflows will fire — and exactly why the others won't — before you push. An honest fires / skipped / unknown / error trigger explainer built on GitHub's own workflow parser.", "url": "https://actwhy.vercel.app", "downloadUrl": "https://github.com/Co-Messi/actwhy", "softwareHelp": "https://github.com/Co-Messi/actwhy", @@ -206,6 +206,19 @@ } .hero__sub code { color: var(--ink); background: var(--panel); border: 1px solid var(--rule); padding: 0 5px; } .hero__cta { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; margin-bottom: 34px; } + .hero-primary { + display: inline-flex; align-items: center; justify-content: center; gap: 10px; + padding: 12px 17px; border: 1px solid var(--ink); background: var(--ink); + color: var(--bg); font-size: 12px; font-weight: 700; letter-spacing: .08em; + text-transform: uppercase; box-shadow: 4px 4px 0 var(--rule-2); + transition: transform .14s ease, box-shadow .14s ease; + } + .hero-primary:hover { + color: var(--bg); border-bottom-color: var(--ink); transform: translate(-1px, -1px); + box-shadow: 6px 6px 0 var(--rule-2); + } + .hero-primary::after { content: "↓"; font-size: 14px; } + .hero-secondary { color: var(--ink-dim); font-size: 12px; border-bottom-color: var(--rule-2); } .cmd { display: inline-flex; align-items: center; gap: 12px; @@ -256,6 +269,8 @@ .legend__item[data-v="skipped"] .legend__word { color: var(--skip); } .legend__item[data-v="unknown"] .legend__eye { color: var(--unknown); border: 1.5px solid rgba(255,176,32,.5); } .legend__item[data-v="unknown"] .legend__word { color: var(--unknown); } + .legend__item[data-v="error"] .legend__eye { color: var(--error); border: 1.5px solid rgba(209,131,255,.5); } + .legend__item[data-v="error"] .legend__word { color: var(--error); } .legend__caption { display: flex; align-items: center; padding: 13px 18px; color: var(--ink-faint); font-size: 11px; letter-spacing: 0.05em; @@ -268,6 +283,25 @@ display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr); gap: 24px; align-items: start; } + .ownership { + grid-column: 1 / -1; display: flex; align-items: center; justify-content: space-between; + gap: 22px; padding: 16px 18px; border: 1px solid var(--rule-2); + background: linear-gradient(90deg, var(--plate), var(--panel)); + box-shadow: inset 0 1px 0 var(--rule-hi); + } + .ownership__copy { display: flex; flex-direction: column; gap: 3px; } + .ownership__eyebrow { + color: var(--unknown); font-size: 10px; text-transform: uppercase; + letter-spacing: .16em; + } + .ownership strong { font-size: 14px; color: var(--ink); } + .ownership span:last-child { color: var(--ink-dim); font-size: 12px; } + .ownership__button { + flex: none; border: 1px solid var(--ink-dim); background: var(--bg); color: var(--ink); + padding: 9px 12px; font: 700 11px var(--mono); letter-spacing: .08em; + text-transform: uppercase; cursor: pointer; + } + .ownership__button:hover { border-color: var(--ink); background: var(--ink); color: var(--bg); } /* an instrument plate: sharp, hairline-bounded, with corner registration ticks */ .panel { @@ -319,9 +353,12 @@ .share-note { padding: 9px 14px; border-bottom: 1px solid var(--rule); font-size: 11px; color: var(--ink-faint); letter-spacing: 0.01em; line-height: 1.5; - display: flex; gap: 8px; align-items: flex-start; background: var(--inset); + display: block; background: var(--inset); + } + .share-note::before { + content: "▶"; color: var(--ink-dim); font-size: 8px; + display: inline-block; margin-right: 8px; vertical-align: 1px; } - .share-note::before { content: "▶"; color: var(--ink-dim); font-size: 8px; margin-top: 4px; flex: none; } .share-note b { color: var(--ink-dim); font-weight: 700; } /* tabs — index cards clipped onto the editor */ @@ -434,6 +471,23 @@ .summary__totals { display: flex; align-items: center; gap: 9px; font-size: 11px; color: var(--ink-faint); letter-spacing: 0.06em; text-transform: uppercase; } .summary__stat { font-variant-numeric: tabular-nums; } + .resolution { + display: grid; grid-template-columns: minmax(150px, .7fr) minmax(0, 1.3fr); + gap: 14px; align-items: center; margin: -3px 0 16px; padding: 12px; + border: 1px solid var(--rule); background: var(--inset); + } + .resolution__copy { display: flex; flex-direction: column; gap: 3px; } + .resolution__eyebrow { + color: var(--ink-faint); font-size: 9px; letter-spacing: .16em; text-transform: uppercase; + } + .resolution__title { color: var(--ink-dim); font-size: 11px; } + .resolution__actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; } + .resolution__action { + border: 1px solid var(--rule-2); background: var(--panel); color: var(--ink-dim); + padding: 7px 9px; font: 600 10px var(--mono); cursor: pointer; + } + .resolution__action:hover { color: var(--ink); border-color: var(--ink-dim); } + /* banners — master-alarm annunciator states */ .banner { border-radius: 0; padding: 18px; margin-bottom: 18px; position: relative; overflow: hidden; } .banner--nothing { @@ -493,7 +547,6 @@ .wf { position: relative; background: var(--panel); border: 1px solid var(--rule); border-radius: 0; padding: 13px 15px 13px 20px; - animation: powerOn .16s ease-out both; } /* the rail: solid=fires, hatch=skipped, hazard-stripe=unknown */ .wf::before { @@ -582,12 +635,13 @@ .job .reason { margin-top: 7px; padding-left: 28px; } .job .reason__msg { font-size: 12.5px; } - @keyframes powerOn { from { opacity: 0.35; transform: translateY(3px); } to { opacity: 1; transform: none; } } - .wf-list .wf:nth-child(1) { animation-delay: 0s; } - .wf-list .wf:nth-child(2) { animation-delay: .03s; } - .wf-list .wf:nth-child(3) { animation-delay: .06s; } - .wf-list .wf:nth-child(4) { animation-delay: .09s; } - .wf-list .wf:nth-child(n+5) { animation-delay: .12s; } + @keyframes verdictChanged { + 0% { box-shadow: inset 0 0 0 1px transparent; } + 35% { box-shadow: inset 0 0 0 1px var(--ink-dim), 0 0 18px rgba(236,233,225,.08); } + 100% { box-shadow: inset 0 0 0 1px transparent; } + } + .results--verdict-changed .summary, + .results--verdict-changed .banner { animation: verdictChanged .55s ease-out; } /* ============================== sections ========================== */ section.band { padding: 54px 0; border-top: 1px solid var(--rule); position: relative; } @@ -652,6 +706,8 @@ .foot__note { flex-basis: 100%; color: var(--ink-faint); font-size: 11px; padding-top: 14px; border-top: 1px solid var(--rule); line-height: 1.5; } .foot__note b { color: var(--ink-dim); } + .mobile-verdict { display: none; } + /* ============================= responsive ========================= */ @media (max-width: 940px) { .play__grid { grid-template-columns: minmax(0, 1fr); } @@ -662,6 +718,17 @@ .install-grid { grid-template-columns: minmax(0, 1fr); max-width: none; } .foot__built { text-align: left; } h1.hero__title { max-width: 22ch; } + body { padding-bottom: 72px; } + .mobile-verdict { + position: fixed; z-index: 60; left: 18px; right: 18px; bottom: 14px; + display: flex; align-items: center; justify-content: space-between; gap: 14px; + padding: 11px 13px; border: 1px solid var(--ink-dim); background: rgba(16,17,19,.96); + color: var(--ink); font: 700 11px var(--mono); letter-spacing: .04em; + box-shadow: 0 8px 30px rgba(0,0,0,.55), inset 0 1px 0 var(--rule-hi); + backdrop-filter: blur(10px); cursor: pointer; + } + .mobile-verdict__action { color: var(--unknown); text-transform: uppercase; font-size: 9px; letter-spacing: .1em; } + .mobile-verdict--changed { animation: verdictChanged .55s ease-out; } } @media (max-width: 560px) { .wrap { padding: 0 18px; } @@ -674,6 +741,13 @@ .summary { flex-wrap: wrap; } .legend__item { flex: 1 1 100%; border-right: 0; border-bottom: 1px solid var(--rule); } .legend__caption { border-left: 0; } + .ownership { align-items: flex-start; flex-direction: column; gap: 12px; } + .ownership__button { width: 100%; } + .resolution { grid-template-columns: 1fr; } + .resolution__actions { justify-content: flex-start; } + .resolution__action { flex: 1 1 auto; } + .hero__cta { align-items: stretch; } + .hero-primary { width: 100%; } } @media (prefers-reduced-motion: reduce) { @@ -713,21 +787,20 @@

Paste your GitHub Actions YAML, simulate a push or pull request, and get an instant, honest verdict for every workflow — down to the exact filter or if: condition that stops it. Runs entirely in your browser.

- @@ -735,6 +808,15 @@

Playground: simulate GitHub Actions triggers in your browser

+
+
+ Your turn + Replace this example with your workflow. + Paste the YAML you are debugging, then change one branch or path and watch the verdict update. +
+ +
+
@@ -750,7 +832,7 @@

Playground: simulate GitHub Actions triggers in your

- +
@@ -810,11 +892,11 @@

Playground: simulate GitHub Actions triggers in your

- Advanced — commit message + Advanced — HEAD commit message
-
Used by conditions like contains(github.event.head_commit.message, '[skip ci]').
+
Used by message conditions and GitHub's native [skip ci] / skip-checks: true directives for pushes and pull requests.
@@ -906,6 +988,11 @@

Install the CLI

+ +
Built on @actions/workflow-parser + @actions/expressions.
-
Privacy: everything runs in your browser. Share links carry your pasted workflow encoded in the URL itself — nothing is ever uploaded to a server.
+
Privacy: evaluation runs locally with zero telemetry. Share links contain your YAML and event data in the URL fragment; review them before pasting into browser history, chat, issue trackers, or logs.
diff --git a/web/src/actions.ts b/web/src/actions.ts new file mode 100644 index 0000000..fa2a81d --- /dev/null +++ b/web/src/actions.ts @@ -0,0 +1,78 @@ +import type { Report } from "../../src/core/types.js"; + +export type ResolutionActionId = + | "test-branch" + | "add-path" + | "switch-pr" + | "copy-reason" + | "share"; + +export interface ResolutionAction { + id: ResolutionActionId; + label: string; + detail: string; +} + +const BRANCH_REASONS = new Set([ + "branch-filter-no-match", + "branch-ignored", + "base-branch-filter-no-match", + "base-branch-ignored", +]); + +const PATH_REASONS = new Set([ + "paths-filter-no-match", + "paths-all-ignored", + "changed-files-unknown", + "no-changed-files", +]); + +function primaryReason(report: Report) { + return ( + report.closestMiss?.reason ?? + report.workflows.find((workflow) => workflow.reasons.length > 0)?.reasons[0] + ); +} + +/** Short, scenario-specific actions that help turn a verdict into a next test. */ +export function resolutionActions(report: Report): ResolutionAction[] { + const reason = primaryReason(report); + const actions: ResolutionAction[] = []; + + if (reason && BRANCH_REASONS.has(reason.code)) { + actions.push({ + id: "test-branch", + label: "Test another branch", + detail: "Focus the branch input and try the ref you intend to push.", + }); + } else if (reason && PATH_REASONS.has(reason.code)) { + actions.push({ + id: "add-path", + label: "Add a changed path", + detail: "Focus changed files and add the path you expect GitHub to diff.", + }); + } + + if (report.event.kind === "push" && report.event.tag === undefined) { + actions.push({ + id: "switch-pr", + label: "Switch to pull request", + detail: "See whether the PR trigger makes a different decision.", + }); + } + + if (reason) { + actions.push({ + id: "copy-reason", + label: "Copy the reason", + detail: reason.message, + }); + } + + actions.push({ + id: "share", + label: "Share this scenario", + detail: "Copy a URL containing the current workflow and simulated event.", + }); + return actions; +} diff --git a/web/src/engine.ts b/web/src/engine.ts index 0cd732e..0e9ce30 100644 --- a/web/src/engine.ts +++ b/web/src/engine.ts @@ -56,6 +56,7 @@ export function buildSpec(state: AppState): EventSpec { if (head) pr.head = head; const activity = state.prActivity.trim(); if (activity) pr.activityType = activity; + if (commitMessage) pr.commitMessage = commitMessage; return pr; } diff --git a/web/src/main.ts b/web/src/main.ts index 6b452da..003edfd 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -3,7 +3,8 @@ * re-evaluating (debounced) on every change and rendering the verdict tree. */ import { el, must, clear } from "./dom.js"; -import { evaluate, type AppState, type EventKind } from "./engine.js"; +import { evaluate, type AppState, type EventKind, type Report } from "./engine.js"; +import type { ResolutionActionId } from "./actions.js"; import { EXAMPLES, cloneExampleState } from "./examples.js"; import { encodeState, decodeState } from "./share.js"; import { renderReport, renderError } from "./render.js"; @@ -40,14 +41,49 @@ const resultsEl = must("#results"); const exampleSelect = must("#example-select"); const shareBtn = must("#share-btn"); const shareFeedback = must("#share-feedback"); +const focusEditorBtn = must("#focus-editor"); +const mobileVerdict = must("#mobile-verdict"); +const mobileVerdictText = must("#mobile-verdict-text"); const pushFields = must("#push-fields"); const tagFields = must("#tag-fields"); const prFields = must("#pr-fields"); -const commitField = must("#commit-field"); // --------------------------------------------------------------- evaluate --- let debounceTimer: ReturnType | undefined; let runToken = 0; +let verdictSignature = ""; + +function reportSignature(report: Report): string { + return report.workflows + .map((workflow) => { + const jobs = workflow.jobs.map((job) => `${job.id}:${job.verdict}`).join(","); + return `${workflow.file}:${workflow.verdict}:${jobs}`; + }) + .join("|"); +} + +function updateVerdictSummary(report: Report): void { + const summary = report.summary; + const nextSignature = reportSignature(report); + const changed = verdictSignature !== "" && nextSignature !== verdictSignature; + verdictSignature = nextSignature; + + const parts = [`${summary.workflowsFiring}/${summary.workflowsTotal} fire`]; + if (summary.workflowsSkipped > 0) parts.push(`${summary.workflowsSkipped} skipped`); + if (summary.workflowsUnknown > 0) parts.push(`${summary.workflowsUnknown} unknown`); + if (summary.workflowsError > 0) parts.push(`${summary.workflowsError} error`); + mobileVerdictText.textContent = parts.join(" · "); + mobileVerdict.hidden = false; + + resultsEl.classList.toggle("results--verdict-changed", changed); + mobileVerdict.classList.toggle("mobile-verdict--changed", changed); + if (changed) { + window.setTimeout(() => { + resultsEl.classList.remove("results--verdict-changed"); + mobileVerdict.classList.remove("mobile-verdict--changed"); + }, 650); + } +} async function runEvaluate(): Promise { const token = ++runToken; @@ -55,6 +91,7 @@ async function runEvaluate(): Promise { const report = await evaluate(state); if (token !== runToken) return; // a newer run superseded this one renderReport(report, resultsEl); + updateVerdictSummary(report); } catch (err) { if (token !== runToken) return; renderError(err instanceof Error ? err.message : String(err), resultsEl); @@ -213,7 +250,6 @@ function updateEventVisibility(): void { pushFields.hidden = state.event !== "push"; tagFields.hidden = state.event !== "tag"; prFields.hidden = state.event !== "pull_request"; - commitField.hidden = state.event === "pull_request"; } /** Push current state values into the control inputs. */ @@ -233,6 +269,14 @@ function syncControls(): void { // ------------------------------------------------------------------ share --- let feedbackTimer: ReturnType | undefined; +function showShareFeedback(message: string): void { + clear(shareFeedback); + shareFeedback.append(document.createTextNode(message)); + shareFeedback.classList.add("share-feedback--show"); + clearTimeout(feedbackTimer); + feedbackTimer = setTimeout(() => shareFeedback.classList.remove("share-feedback--show"), 2600); +} + async function share(): Promise { const hash = "#" + encodeState(state); history.replaceState(null, "", location.pathname + location.search + hash); @@ -244,11 +288,35 @@ async function share(): Promise { } catch { copied = false; } - clear(shareFeedback); - shareFeedback.append(document.createTextNode(copied ? "link copied" : "link in address bar")); - shareFeedback.classList.add("share-feedback--show"); - clearTimeout(feedbackTimer); - feedbackTimer = setTimeout(() => shareFeedback.classList.remove("share-feedback--show"), 2200); + showShareFeedback(copied ? "link copied — review before sharing" : "link ready in address bar"); +} + +function focusField(field: HTMLInputElement | HTMLTextAreaElement): void { + field.scrollIntoView({ behavior: "smooth", block: "center" }); + field.focus({ preventScroll: true }); + if (field instanceof HTMLInputElement) field.select(); + else field.setSelectionRange(field.value.length, field.value.length); +} + +async function handleResolution(id: ResolutionActionId, text: string): Promise { + if (id === "test-branch") { + focusField(state.event === "pull_request" ? prBaseEl : branchEl); + } else if (id === "add-path") { + focusField(changedEl); + } else if (id === "switch-pr") { + setEvent("pull_request"); + prBaseEl.scrollIntoView({ behavior: "smooth", block: "center" }); + prBaseEl.focus({ preventScroll: true }); + } else if (id === "copy-reason") { + try { + await navigator.clipboard.writeText(text); + showShareFeedback("reason copied"); + } catch { + showShareFeedback("copy unavailable"); + } + } else { + await share(); + } } // --------------------------------------------------------------- examples --- @@ -315,6 +383,14 @@ function wireInputs(): void { }); shareBtn.addEventListener("click", () => void share()); + focusEditorBtn.addEventListener("click", () => focusField(editorEl)); + mobileVerdict.addEventListener("click", () => { + document.querySelector(".panel--out")?.scrollIntoView({ behavior: "smooth", block: "start" }); + }); + resultsEl.addEventListener("actwhy:resolution", (event) => { + const detail = (event as CustomEvent<{ id: ResolutionActionId; text: string }>).detail; + if (detail) void handleResolution(detail.id, detail.text); + }); for (const btn of Array.from(document.querySelectorAll("[data-copy]"))) { btn.addEventListener("click", async () => { diff --git a/web/src/render.ts b/web/src/render.ts index 12962ed..48c373d 100644 --- a/web/src/render.ts +++ b/web/src/render.ts @@ -11,6 +11,7 @@ import type { VerdictKind, WorkflowVerdict, } from "../../src/core/types.js"; +import { resolutionActions } from "./actions.js"; const GLYPH: Record = { fires: "✔", @@ -154,6 +155,42 @@ function bannerNode(report: Report): HTMLElement | null { return banner; } +function resolutionNode(report: Report): HTMLElement { + const section = el("section", { + class: "resolution", + "aria-label": "Try another scenario", + }); + const copy = el("div", { class: "resolution__copy" }); + copy.append(el("span", { class: "resolution__eyebrow" }, "Next test")); + copy.append(el("span", { class: "resolution__title" }, "Turn this verdict into an answer")); + section.append(copy); + + const controls = el("div", { class: "resolution__actions" }); + for (const action of resolutionActions(report)) { + const button = el( + "button", + { + class: "resolution__action", + type: "button", + title: action.detail, + "data-resolution-action": action.id, + }, + action.label, + ); + button.addEventListener("click", () => { + section.dispatchEvent( + new CustomEvent("actwhy:resolution", { + bubbles: true, + detail: { id: action.id, text: action.detail }, + }), + ); + }); + controls.append(button); + } + section.append(controls); + return section; +} + /** Render the whole report into `mount`. */ export function renderReport(report: Report, mount: HTMLElement): void { clear(mount); @@ -195,6 +232,7 @@ export function renderReport(report: Report, mount: HTMLElement): void { const banner = bannerNode(report); if (banner) mount.append(banner); + mount.append(resolutionNode(report)); // Announce a one-line summary instead of the whole rebuilt tree — a // full-subtree aria-live region re-reads everything on each keystroke.