@silverassist/agents-toolkit 2.5.1 → 2.6.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.
@@ -25,9 +25,9 @@ This skill provides guidelines for organizing code following Domain-Driven Desig
25
25
 
26
26
  ### ❌ DON'T
27
27
 
28
- - Create generic folders like "helpers", "services", "utils" at root level
28
+ - Create generic folders like `src/helpers/`, `src/services/`, or `src/utils/` directly under `src/` (a scoped `src/lib/utils/` for minimal cross-domain primitives is allowed)
29
29
  - Mix different domain concerns in the same folder
30
- - Create deeply nested folder structures (max 3 levels)
30
+ - Create folder hierarchies deeper than 3 levels below `src/` (e.g., `src/components/auth/login-form/` is allowed; `src/components/auth/login-form/subform/` is not)
31
31
  - Use abbreviations in folder names
32
32
 
33
33
  ## Project Structure
@@ -228,7 +228,7 @@ Ask these questions to identify domains:
228
228
  ### ❌ Generic Folder Anti-Patterns
229
229
 
230
230
  ```
231
- # ❌ BAD: Generic folders
231
+ # ❌ BAD: Generic folders at src/ root
232
232
  src/
233
233
  ├── components/
234
234
  ├── helpers/ # What kind of helpers?
@@ -237,6 +237,11 @@ src/
237
237
  └── types/ # Types should live with their domain
238
238
  ```
239
239
 
240
+ ### Shared TypeScript Types
241
+
242
+ - If a type is used by **3 or more domains**, place it in `src/lib/types/` — the shared-types exception lives under the allowed `src/lib/` scope, **not** a generic `src/types/` root (which the anti-pattern above forbids). Export it from that folder's own barrel (`src/lib/types/index.ts`) and import it as `@/lib/types`. A centrally shared primitive has **no owning domain**, so do not route it through a domain barrel — that would invert the intended dependency direction (domains → shared, never shared → a domain).
243
+ - Otherwise, **colocate the type with its primary domain** (e.g., `src/lib/payment/types.ts`, `src/components/auth/types.ts`) and re-export it through that domain's barrel when other domains need it.
244
+
240
245
  ### ✅ Domain-Oriented Structure
241
246
 
242
247
  ```
@@ -0,0 +1,215 @@
1
+ ---
2
+ name: github-review-management
3
+ description: Fetch, reply to, resolve, and close GitHub pull-request review threads with the gh CLI and GraphQL API. Use when handling Copilot or human PR reviews end-to-end.
4
+ ---
5
+
6
+ # Silver Assist — GitHub Review Management
7
+
8
+ Reference knowledge for clearing GitHub pull-request reviews programmatically. This skill
9
+ backs the `/resolve-github-reviews` prompt: it explains the data model, which API does what,
10
+ the exact commands, and the Copilot-specific edge cases. Use it when a PR has review threads
11
+ (Copilot or human) that need to be addressed, replied to, resolved, and verified to `0`.
12
+
13
+ ## When to Use
14
+
15
+ - A PR has open Copilot or human review comments to clear before merge
16
+ - You need to **resolve** review threads (not just reply) — which REST cannot do
17
+ - Debugging why a reply or resolve call fails (404 on replies, permissions on the mutation)
18
+ - Handling Copilot suppressed / low-confidence notes or `isOutdated` threads
19
+ - Automating the "reply → resolve → assert 0 unresolved" loop across per-commit review rounds
20
+
21
+ ## The data model (get this right first)
22
+
23
+ GitHub exposes three distinct things — conflating them is the usual source of bugs:
24
+
25
+ | Concept | What it is | API surface |
26
+ |---------|-----------|-------------|
27
+ | **Review (submission)** | The top-level verdict on a PR: `APPROVED` / `CHANGES_REQUESTED` / `COMMENTED` | `gh pr review`, REST `/pulls/{n}/reviews` |
28
+ | **Review comment** | A single inline comment anchored to a file/line (has a `databaseId`) | REST `/pulls/{n}/comments` |
29
+ | **Review thread** | An inline comment **plus its replies**, carrying the `isResolved` flag | **GraphQL `reviewThreads` only** |
30
+
31
+ Only **threads** have `isResolved`, and **only GraphQL can flip it**. The REST comments API
32
+ lists and replies to comments but has no concept of resolving.
33
+
34
+ ## Which API does what
35
+
36
+ | Operation | API | Command |
37
+ |-----------|-----|---------|
38
+ | List unresolved threads | GraphQL | `reviewThreads(first:100){ nodes{ id isResolved path line comments(first:1){ nodes{ databaseId } } } }` |
39
+ | Reply to a thread | REST | `POST /pulls/{n}/comments/{comment_id}/replies` |
40
+ | Reply fallback (404) | REST | `POST /pulls/{n}/comments` with `in_reply_to={comment_id}` |
41
+ | Acknowledge suppressed notes | REST | `gh pr comment {n} --body "…"` (PR-level) |
42
+ | **Resolve a thread** | **GraphQL** | `resolveReviewThread(input:{threadId:$id})` |
43
+ | Verify 0 unresolved | GraphQL | re-query `reviewThreads`, count `isResolved == false` |
44
+
45
+ ## Resolve locations
46
+
47
+ ```bash
48
+ REPO_SLUG=$(gh repo view --json nameWithOwner -q .nameWithOwner) # owner/repo
49
+ OWNER=${REPO_SLUG%/*}; REPO=${REPO_SLUG#*/}
50
+ PR=$(gh pr view --json number -q .number) # current branch's PR
51
+ ```
52
+
53
+ ## 1. List unresolved threads (paginated)
54
+
55
+ A PR can have more than 100 threads, so a single `first:100` page is not enough. Walk the
56
+ connection with `pageInfo { endCursor hasNextPage }` and collect every node before filtering.
57
+
58
+ ```bash
59
+ > /tmp/review-threads.jsonl
60
+ CURSOR=null
61
+ while : ; do
62
+ # First page: there is no cursor yet ($CURSOR is the string "null"). Passing it as
63
+ # `-f after="null"` would send the literal string "null" — an invalid cursor, not a
64
+ # "start from the beginning" signal. Omit `after` on this pass so the query starts at the
65
+ # beginning; pass the real endCursor afterward. (gh's typed `-F after=null` instead sends
66
+ # a real GraphQL null.)
67
+ if [ "$CURSOR" = "null" ]; then
68
+ AFTER_ARGS=()
69
+ else
70
+ AFTER_ARGS=(-f after="$CURSOR")
71
+ fi
72
+ PAGE=$(gh api graphql -F owner="$OWNER" -F repo="$REPO" -F pr="$PR" "${AFTER_ARGS[@]}" -f query='
73
+ query($owner:String!, $repo:String!, $pr:Int!, $after:String) {
74
+ repository(owner:$owner, name:$repo) {
75
+ pullRequest(number:$pr) {
76
+ reviewThreads(first:100, after:$after) {
77
+ pageInfo { endCursor hasNextPage }
78
+ nodes {
79
+ id
80
+ isResolved
81
+ isOutdated
82
+ path
83
+ line
84
+ comments(first:1) { nodes { databaseId body author { login } } }
85
+ }
86
+ }
87
+ }
88
+ }
89
+ }')
90
+ # Fail fast: a GraphQL/auth/network error must not be mistaken for "no threads".
91
+ if ! echo "$PAGE" | jq -e '.data.repository.pullRequest.reviewThreads' >/dev/null 2>&1; then
92
+ echo "ERROR: GraphQL request failed or returned an unexpected shape:" >&2
93
+ echo "$PAGE" >&2
94
+ exit 1
95
+ fi
96
+ echo "$PAGE" | jq -c '.data.repository.pullRequest.reviewThreads.nodes[]' >> /tmp/review-threads.jsonl
97
+ HAS_NEXT=$(echo "$PAGE" | jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage')
98
+ CURSOR=$(echo "$PAGE" | jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.endCursor')
99
+ [ "$HAS_NEXT" = "true" ] || break
100
+ done
101
+
102
+ jq -r '
103
+ select(.isResolved == false)
104
+ | "\(.id)\t\(.comments.nodes[0].databaseId)\t\(.path):\(.line)"
105
+ ' /tmp/review-threads.jsonl
106
+ ```
107
+
108
+ Each row gives `threadId` (to resolve) and `commentId` = `databaseId` (to reply). A `null`
109
+ `databaseId` marks a suppressed note — see below.
110
+
111
+ ## 2. Reply to a thread
112
+
113
+ ```bash
114
+ # Primary: reply directly on the thread.
115
+ gh api "repos/$OWNER/$REPO/pulls/$PR/comments/$COMMENT_ID/replies" \
116
+ -f body="Fixed in <sha>: <what changed>."
117
+
118
+ # Fallback when the replies endpoint 404s: link via in_reply_to.
119
+ gh api "repos/$OWNER/$REPO/pulls/$PR/comments" \
120
+ -f body="Fixed in <sha>: <what changed>." \
121
+ -F in_reply_to="$COMMENT_ID"
122
+ ```
123
+
124
+ ## 3. Resolve the thread (GraphQL — the only way)
125
+
126
+ ```bash
127
+ gh api graphql -f id="$THREAD_ID" -f query='
128
+ mutation($id:ID!) {
129
+ resolveReviewThread(input:{threadId:$id}) {
130
+ thread { isResolved }
131
+ }
132
+ }'
133
+ ```
134
+
135
+ Batch all addressed threads:
136
+
137
+ ```bash
138
+ for THREAD_ID in $THREAD_IDS; do
139
+ gh api graphql -f id="$THREAD_ID" -f query='mutation($id:ID!){ resolveReviewThread(input:{threadId:$id}){ thread { isResolved } } }'
140
+ done
141
+ ```
142
+
143
+ > To reopen a thread (rarely needed), the counterpart mutation is `unresolveReviewThread`.
144
+
145
+ ## 4. Verify 0 unresolved (the close-the-loop check)
146
+
147
+ ```bash
148
+ > /tmp/review-threads-remaining.jsonl
149
+ CURSOR=null
150
+ while : ; do
151
+ if [ "$CURSOR" = "null" ]; then
152
+ AFTER_ARGS=()
153
+ else
154
+ AFTER_ARGS=(-f after="$CURSOR")
155
+ fi
156
+ PAGE=$(gh api graphql -F owner="$OWNER" -F repo="$REPO" -F pr="$PR" "${AFTER_ARGS[@]}" -f query='
157
+ query($owner:String!, $repo:String!, $pr:Int!, $after:String) {
158
+ repository(owner:$owner, name:$repo) {
159
+ pullRequest(number:$pr) {
160
+ reviewThreads(first:100, after:$after) {
161
+ pageInfo { endCursor hasNextPage }
162
+ nodes { isResolved }
163
+ }
164
+ }
165
+ }
166
+ }')
167
+ # Fail fast: a GraphQL/auth/network error must not be mistaken for "0 remaining".
168
+ if ! echo "$PAGE" | jq -e '.data.repository.pullRequest.reviewThreads' >/dev/null 2>&1; then
169
+ echo "ERROR: GraphQL request failed or returned an unexpected shape:" >&2
170
+ echo "$PAGE" >&2
171
+ exit 1
172
+ fi
173
+ echo "$PAGE" | jq -c '.data.repository.pullRequest.reviewThreads.nodes[]' >> /tmp/review-threads-remaining.jsonl
174
+ HAS_NEXT=$(echo "$PAGE" | jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage')
175
+ CURSOR=$(echo "$PAGE" | jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.endCursor')
176
+ [ "$HAS_NEXT" = "true" ] || break
177
+ done
178
+
179
+ REMAINING=$(jq -s '[.[] | select(.isResolved == false)] | length' /tmp/review-threads-remaining.jsonl)
180
+ if [ "$REMAINING" -eq 0 ]; then
181
+ echo "✅ all resolved"
182
+ else
183
+ echo "❌ $REMAINING remaining"
184
+ exit 1
185
+ fi
186
+ ```
187
+
188
+ > If `isResolved` remains `false` immediately after a successful `resolveReviewThread`
189
+ > mutation, wait 2–3 seconds and re-query — GitHub's GraphQL API can exhibit brief
190
+ > consistency lag after bulk resolves, so a thread already resolved server-side may
191
+ > still report as open on the next read.
192
+
193
+ ## Copilot-specific handling
194
+
195
+ - **Suppressed / low-confidence notes** — Copilot posts some findings without an inline comment
196
+ (`databaseId` is `null`), so there is **no inline comment to reply to**. The thread itself still
197
+ has an `id` and is resolvable — acknowledge the note with a single PR-level comment
198
+ (`gh pr comment`), then resolve the thread by its `id` like any other.
199
+ - **`isOutdated` threads** — when the underlying code moves, the thread is flagged `isOutdated`
200
+ but remains **unresolved**. It never clears itself; resolve it explicitly once handled.
201
+ - **Per-commit review rounds** — Copilot re-reviews after each push, potentially opening new
202
+ threads. Re-run list → reply → resolve → verify until the count is `0`. Trigger a fresh pass
203
+ with `gh pr comment $PR --body "@copilot review"` (unquoted `$PR` — it is the integer PR
204
+ number set earlier via `gh pr view --json number -q .number`, and `gh pr comment` takes
205
+ it as a positional argument, not a string flag value).
206
+
207
+ ## Common failures
208
+
209
+ | Symptom | Cause | Fix |
210
+ |---------|-------|-----|
211
+ | Resolve mutation returns a permissions error | Token lacks `repo` scope | Re-auth: `gh auth refresh -s repo` |
212
+ | `POST …/replies` → 404 | Thread doesn't accept direct replies | Use `in_reply_to` fallback |
213
+ | Reply "works" but thread stays open | Replied via REST, never resolved | Run the GraphQL `resolveReviewThread` |
214
+ | `pr` variable rejected by GraphQL | Passed as a string | Use `-F pr="$PR"` (typed) not `-f pr=...` |
215
+ | Count never reaches 0 | New round opened threads | Re-run the loop after each push |
@@ -5,7 +5,7 @@ description: Caching strategy for Next.js (App Router) frontends consuming the C
5
5
 
6
6
  # Next.js Caching Skill
7
7
 
8
- Canonical caching strategy for Silver Side Next.js (App Router) frontends. Use it when touching data
8
+ Canonical caching strategy for Silver Assist Side Next.js (App Router) frontends. Use it when touching data
9
9
  fetching, route segment configs, the asset/page proxy, image config, or the on-demand revalidate
10
10
  route — and when diagnosing "why isn't this page cached / why is it serving stale".
11
11
 
@@ -76,15 +76,25 @@ Pick one (ordered by risk, lowest first):
76
76
  sets `Cache-Control: public, s-maxage=2592000, stale-while-revalidate=2592000` — the **same**
77
77
  header the ISR pages emit (see `expireTime` under ISR tiers), so the CDN policy is uniform across
78
78
  static and dynamic routes. The origin stays dynamic; the CDN caches the deterministic-per-URL
79
- response. Per-repo regex, no build-time risk — this is what WEB-1069 shipped.
79
+ response. Per-repo regex, no build-time risk — this is what WEB-1069 shipped. **Requires:** a
80
+ per-repo path regex in `src/proxy.ts` that matches exactly the routes you want cached **and**
81
+ those routes must be **public and deterministic per URL** — no auth-, cookie-, or session-
82
+ dependent output. A `public` edge cache serves one stored response to *every* visitor, so a
83
+ personalized route matched here would leak one user's content to others. Never match
84
+ authenticated or personalized paths.
80
85
  2. **`export const dynamic = "force-static"` (interim).** Forces the POST read to `force-cache` and
81
86
  prerenders the route as real ISR (confirmed via `prerender-manifest.json`). Removed in WEB-1058
82
87
  because a CCDS failure at build time cached a **blank page**; safer now that reads throw on 5xx at
83
88
  runtime (a failed revalidation keeps the last good cache), but full prerender is sensitive to
84
- null/bad records keep the page fully null-safe and validate per repo before adopting.
89
+ null/bad records. **Requires:** the page is fully null-safe (every field access guarded); the read
90
+ throws on failure **at build/prerender time too** (not only during runtime revalidation) so a failed
91
+ read aborts generation instead of prerendering a cacheable blank/error page — the exact WEB-1058
92
+ regression; AND the client throws on 5xx at runtime (so ISR keeps the previous version instead of
93
+ caching an error). Re-validate this per repository before adopting the strategy.
85
94
  3. **`cacheComponents: true` + `use cache` (strategic).** Wrap the POST read in `use cache` so its data
86
- lands in the static shell (PPR). Next-recommended long term; larger migration — do **not** mix ad
87
- hoc with route-segment `export const revalidate`.
95
+ lands in the static shell (PPR). Next-recommended long term; larger migration. **Requires:** a
96
+ deliberate PPR migration for the whole route segment; do **not** mix ad hoc with route-segment
97
+ `export const revalidate` (the two models conflict).
88
98
 
89
99
  ---
90
100
 
@@ -164,7 +174,9 @@ is intentionally **long** (WEB-1069):
164
174
  30d revalidate). Set it to `5184000` (60d) so a 30d page yields the same header the proxy sets on
165
175
  dynamic pages: `s-maxage=2592000, stale-while-revalidate=2592000`.
166
176
  - The long default is safe because a missed webhook still self-heals within 30d; use `tags` for
167
- immediate surgical invalidation. Shorter tiers (24h/7d) are fine per-route if a source is volatile.
177
+ immediate surgical invalidation. Shorter tiers (24h/7d) are appropriate per-route when the data
178
+ source updates more frequently than the 30d default — for example, sources that update daily or
179
+ weekly. Sources that update less frequently than 30d should keep the default.
168
180
  - Default to `export const revalidate` over `dynamic = "force-static"`: a failed ISR revalidation then
169
181
  preserves the last good cache instead of overwriting it with a broken page. Pair with a client that
170
182
  **throws on 5xx at runtime** (so ISR keeps the previous version) but returns an error during the
@@ -185,6 +197,10 @@ revalidateTag(tag); // granular per-state / per-city tags
185
197
  if (invalidateCDN) await invalidateCloudFrontPaths([path]);
186
198
  ```
187
199
 
200
+ If `invalidateCloudFrontPaths` throws, log the error and surface it to the caller — do **not**
201
+ silently swallow it. The Next.js cache will be fresh but the CDN will serve stale until its TTL
202
+ expires; an operator must retry the CloudFront invalidation manually.
203
+
188
204
  ---
189
205
 
190
206
  ## Anti-patterns
@@ -226,5 +242,12 @@ curl -sI https://<host>/<care-type>/<state>/<city> | grep -i cache-control
226
242
  ```
227
243
 
228
244
  In the build output, `●` (or "Static"/"ISR") means prerendered; `ƒ` ("Dynamic") means it renders per
229
- request a POST-read page should be the former. `next.config` `logging.fetches.fullUrl: true` shows
230
- per-fetch cache decisions in dev.
245
+ request. Which one to expect depends on the strategy chosen in "Making a POST-read page cacheable":
246
+ strategy **2** (`force-static`) prerenders fully, so it shows `●`; strategy **3** (`use cache` / PPR)
247
+ shows `●` only when the whole route is static — if dynamic holes remain it renders as a **partial
248
+ prerender** and Next marks it `◐` (partial), which is still correct output, so do not reject it.
249
+ Strategy **1** (CDN edge override) intentionally leaves the route as `ƒ` — the page is CDN-cached at
250
+ the edge even though Next classifies it as dynamic. Because the symbol alone is ambiguous across
251
+ strategies, verify with the `curl -sI … | grep -i cache-control` check above and defer to the
252
+ installed Next.js version's build-output legend rather than the symbol. `next.config`
253
+ `logging.fetches.fullUrl: true` shows per-fetch cache decisions in dev.
@@ -41,7 +41,7 @@ plugin-slug/
41
41
 
42
42
  ### Key Principles
43
43
 
44
- 1. **Unified scripts** — `build-release.sh` and `release.yml` are identical across all 6 plugins.
44
+ 1. **Unified scripts** — `build-release.sh` and `release.yml` are structurally identical across all 6 plugins. The only permitted per-plugin variation is the Silver Assist package validation block at the end of `build-release.sh` (see [Validation Checks](#validation-checks)), which may omit checks for packages the plugin does not depend on.
45
45
  2. **Selective copy** — Only runtime files go into the ZIP (no tests, docs, dev configs).
46
46
  3. **Auto-detection** — The build script finds the main plugin file, slug, and version automatically.
47
47
  4. **Checksums** — Every ZIP gets MD5 and SHA-256 checksums.
@@ -80,13 +80,21 @@ git push origin v1.3.6
80
80
 
81
81
  ### Step 1: Update All Versions
82
82
 
83
- Use whichever version script the plugin has:
83
+ **Selection is driven by which script the plugin ships — each plugin ships exactly one variant**,
84
+ and `release.yml` auto-detects the same way (it runs `update-version.sh` if present, else
85
+ `update-version-simple.sh`). Manual runs and CI therefore stay consistent *because only one script
86
+ exists per plugin*; never ship both, or CI's preference for `update-version.sh` would silently
87
+ diverge from a manual `update-version-simple.sh` run.
88
+
89
+ - Full variant (`update-version.sh`): `acf-clone-fields`, `silver-assist-post-revalidate`, `silver-assist-security`.
90
+ - Simple variant (`update-version-simple.sh`): `contact-form-to-api`, `leadgen-app-form`, `nextjs-graphql-hooks`.
91
+ - New plugin: ship **only** `update-version-simple.sh` unless it genuinely needs the full variant.
84
92
 
85
93
  ```bash
86
94
  # Full variant (acf-clone-fields, silver-assist-post-revalidate, silver-assist-security)
87
95
  ./scripts/update-version.sh 1.2.0 --no-confirm --force
88
96
 
89
- # Simple variant (contact-form-to-api, leadgen-app-form, nextjs-graphql-hooks)
97
+ # Simple variant (contact-form-to-api, leadgen-app-form, nextjs-graphql-hooks, new plugins by default)
90
98
  ./scripts/update-version-simple.sh 1.2.0 --no-confirm --force
91
99
  ```
92
100
 
@@ -165,7 +173,7 @@ gh run watch <run-id> --exit-status
165
173
 
166
174
  ## Unified Build Script (`scripts/build-release.sh`)
167
175
 
168
- This script is **identical** across all Silver Assist plugins. When setting up a new plugin, copy it verbatim.
176
+ This script is **structurally identical** across all Silver Assist plugins. When setting up a new plugin, copy it and change only the dependency-specific asset-validation checks a plugin does not need — the single allowed per-plugin variation, documented below (see the Silver Assist package-asset table and [Key Principles](#key-principles)). Everything else must remain identical; copying it verbatim for a plugin that lacks one of those dependencies would leave an invalid validation check that fails its release build.
169
177
 
170
178
  ### What It Does
171
179
 
@@ -406,7 +414,7 @@ The build script validates these Silver Assist package assets exist in the final
406
414
  | Settings Hub CSS | `vendor/silverassist/wp-settings-hub/assets/css/settings-hub.css` |
407
415
  | GitHub Updater JS | `vendor/silverassist/wp-github-updater/assets/js/check-updates.js` |
408
416
 
409
- If the plugin does NOT use these packages, remove the corresponding validation checks.
417
+ If the plugin does NOT depend on one of these packages, omit only that package's validation check when creating the plugin's `build-release.sh`. This is the single allowed per-plugin variation of the script (see [Key Principles](#key-principles)); everything else must remain identical to the template above.
410
418
 
411
419
  ---
412
420
 
@@ -429,6 +437,14 @@ on:
429
437
  description: 'Version to release (e.g. 1.2.0). Leave empty to use plugin file version.'
430
438
  required: false
431
439
 
440
+ # Note: `workflow_dispatch` still performs a REAL release — the "Create GitHub Release"
441
+ # step below publishes the release and creates the `v<version>` tag if it does not
442
+ # already exist. It is NOT a dry run. Use it only to release a version whose source is
443
+ # already committed on the checked-out ref: the "Update version" step edits files inside
444
+ # the CI checkout without committing them back, so an uncommitted bump would ship a ZIP
445
+ # that differs from the repository. For a true dry run, run the build steps in a separate
446
+ # non-publishing workflow that omits the "Create GitHub Release" step.
447
+
432
448
  permissions:
433
449
  contents: write
434
450
 
@@ -457,6 +473,10 @@ jobs:
457
473
  else
458
474
  VERSION=$(grep -o 'Version: [0-9]\+\.[0-9]\+\.[0-9]\+' *.php 2>/dev/null | head -1 | cut -d' ' -f2)
459
475
  fi
476
+ if [ -z "$VERSION" ]; then
477
+ echo "::error::Could not detect version: no tag, no manual input, and no Version: header found in root PHP files."
478
+ exit 1
479
+ fi
460
480
  echo "version=$VERSION" >> $GITHUB_OUTPUT
461
481
  echo "🏷️ Version: $VERSION"
462
482
 
@@ -75,6 +75,24 @@ jest.mock('stripe', () => ({
75
75
 
76
76
  ## Server Action Testing Pattern
77
77
 
78
+ ### Mocking `next/navigation`
79
+
80
+ Server Actions that call `redirect()` or use routing helpers require `next/navigation` to be mocked, since it is not available in the Jest test environment.
81
+
82
+ ```typescript
83
+ // Mock next/navigation BEFORE imports.
84
+ // redirect() must THROW like the real implementation — Next signals a redirect by throwing
85
+ // NEXT_REDIRECT to terminate the action's control flow. A plain `jest.fn()` no-op would let
86
+ // code after redirect() keep running, producing outcomes impossible in production. Throw a
87
+ // sentinel and assert on it (e.g. `await expect(action()).rejects.toThrow(/NEXT_REDIRECT/)`).
88
+ jest.mock('next/navigation', () => ({
89
+ redirect: jest.fn((url) => {
90
+ throw new Error(`NEXT_REDIRECT: ${url}`);
91
+ }),
92
+ useRouter: jest.fn(() => ({ push: jest.fn() })),
93
+ }));
94
+ ```
95
+
78
96
  ### Basic Server Action Test
79
97
 
80
98
  ```typescript
@@ -113,6 +131,26 @@ describe('submitWizardToSalesforce', () => {
113
131
  expect(result.success).toBe(true);
114
132
  expect(result.leadId).toBeDefined();
115
133
  });
134
+
135
+ it('should return failure state when the API rejects', async () => {
136
+ // Simulate an error thrown by the mocked dependency
137
+ mockCreateLead.mockRejectedValue(new Error('API error'));
138
+
139
+ const formData = new FormData();
140
+ formData.append('email', 'test@example.com');
141
+ formData.append('firstName', 'John');
142
+
143
+ const result = await submitWizardToSalesforce(
144
+ { success: false, message: '', timestamp: 0 },
145
+ formData
146
+ );
147
+
148
+ // Assert the error path for THIS action: it catches the API failure and returns a
149
+ // failure state. Match the action's own contract — actions that instead throw (e.g.
150
+ // via redirect(), see the mock above) must be asserted with `rejects.toThrow(...)`.
151
+ expect(result.success).toBe(false);
152
+ expect(result.message).toContain('error');
153
+ });
116
154
  });
117
155
  ```
118
156
 
@@ -0,0 +1,225 @@
1
+ ---
2
+ name: tsdoc-standards
3
+ description: Write and enforce TSDoc (not JSDoc) in TypeScript. Use when adding or reviewing doc comments on functions, components, interfaces, or file headers. Covers allowed tags, forbidden JSDoc patterns, and templates.
4
+ ---
5
+
6
+ # Silver Assist — TSDoc Standards
7
+
8
+ This skill is the deep reference for writing and reviewing documentation comments in
9
+ TypeScript. All doc comments follow the **[TSDoc](https://tsdoc.org/)** standard — **not
10
+ JSDoc**. The enforced, always-on rules live in `tsdoc-standards.instructions.md`
11
+ (`applyTo: "**/*.{ts,tsx}"`); this skill adds the *why*, expanded examples, and a review
12
+ checklist for on-demand use.
13
+
14
+ ## When to Use
15
+
16
+ - Adding doc comments to functions, Server Actions, React components, hooks, or utilities
17
+ - Documenting interfaces, types, enums, or module/file headers
18
+ - Reviewing a PR and deciding whether a comment is TSDoc-correct
19
+ - Migrating an existing file from JSDoc (`{type}` braces, `@template`, `@module`) to TSDoc
20
+ - Answering "which tag do I use for X?" or "is `@fileoverview` allowed?"
21
+
22
+ ## Why TSDoc, not JSDoc
23
+
24
+ TypeScript already carries the type information. JSDoc duplicates it in `{braces}`, which:
25
+
26
+ - **Drifts** — the `{string}` in the comment and the `: string` in the signature diverge over time.
27
+ - **Is redundant** — editors and `tsc` read the real types; the comment adds nothing.
28
+ - **Breaks API extractors** — tools like [API Extractor](https://api-extractor.com/) and the
29
+ TSDoc parser reject JSDoc-only tags (`@typedef`, `@callback`, `@fileoverview`).
30
+
31
+ TSDoc keeps prose in the comment and types in the code — one source of truth for each.
32
+
33
+ ## Core Rules (with rationale)
34
+
35
+ ### 1. No type annotations in `@param` / `@returns`
36
+
37
+ The type is in the signature; the comment describes *meaning*, not *type*.
38
+
39
+ ```typescript
40
+ // ❌ JSDoc — the {string} duplicates the signature
41
+ /** @param {string} slug - Community slug */
42
+
43
+ // ✅ TSDoc — hyphen required after the name; no braces
44
+ /** @param slug - Community slug */
45
+ ```
46
+
47
+ ### 2. `@typeParam` for generics (not `@template`)
48
+
49
+ ```typescript
50
+ // ❌ @template is a JSDoc/Closure tag
51
+ /** @template T */
52
+
53
+ // ✅ TSDoc generic parameter
54
+ /** @typeParam T - The item shape returned by the fetcher */
55
+ ```
56
+
57
+ ### 3. `@packageDocumentation` for file/module headers (not `@module`)
58
+
59
+ Place it in the **first** comment of the file. It marks module-level docs for extractors.
60
+
61
+ ```typescript
62
+ /**
63
+ * @packageDocumentation
64
+ * CCDS geo-search utilities for state, city, and community lookups.
65
+ */
66
+ ```
67
+
68
+ ### 4. Document interface members inline (not `@property`)
69
+
70
+ Each member gets its own leading comment — it shows on hover for that specific field.
71
+
72
+ ```typescript
73
+ interface Community {
74
+ /** The community name. */
75
+ name: string;
76
+ /** The city where the community is located. */
77
+ city: string;
78
+ /** @defaultValue `false` */
79
+ featured?: boolean;
80
+ }
81
+ ```
82
+
83
+ ### 5. Overloaded functions — document each overload signature
84
+
85
+ For overloaded functions, place the TSDoc comment on **each overload signature individually**.
86
+ Do **not** place it on the implementation signature — tooling (editors, `tsc`, API extractors)
87
+ will not surface it to consumers, who only see the overload signatures.
88
+
89
+ ```typescript
90
+ // ✅ TSDoc on each overload; implementation signature is undocumented
91
+ /**
92
+ * Fetches a community by its slug.
93
+ *
94
+ * @param slug - The community slug
95
+ * @returns The matching community, or `undefined`
96
+ */
97
+ export function getCommunity(slug: string): Community | undefined;
98
+ /**
99
+ * Fetches a community by its numeric ID.
100
+ *
101
+ * @param id - The community ID
102
+ * @returns The matching community, or `undefined`
103
+ */
104
+ export function getCommunity(id: number): Community | undefined;
105
+ export function getCommunity(key: string | number): Community | undefined {
106
+ // implementation
107
+ }
108
+ ```
109
+
110
+ ## Allowed Tags — quick reference
111
+
112
+ | Tag | Format | Notes |
113
+ |-----|--------|-------|
114
+ | `@param` | `@param name - Description` | Hyphen required; no type braces |
115
+ | `@returns` | `@returns Description` | No type braces |
116
+ | `@remarks` | Block | Extended description / side effects |
117
+ | `@example` | Block | Fenced code (` ```typescript `) |
118
+ | `@throws` | Block | Documented exceptions |
119
+ | `@see` | Block | Cross-reference or URL |
120
+ | `@deprecated` | Block | Add migration guidance |
121
+ | `@defaultValue` | Block | Default of a property |
122
+ | `@typeParam` | `@typeParam T - Description` | Generic parameter |
123
+ | `@packageDocumentation` | Modifier | First comment in the file |
124
+ | `{@link symbol}` | Inline | Hyperlink to a symbol |
125
+
126
+ > `@param` / `@returns` are **optional** — add them only when they add clarity (non-obvious
127
+ > params, complex return shapes). Never add them to interfaces, types, or constants.
128
+
129
+ ## Templates
130
+
131
+ ### Server Action
132
+
133
+ ````typescript
134
+ /**
135
+ * Submits the contact form and sends a notification email.
136
+ *
137
+ * @remarks
138
+ * Validates server-side, creates a DB record, dispatches email.
139
+ *
140
+ * @param formData - Submitted form data from the contact page
141
+ *
142
+ * @throws When the email service is unavailable
143
+ */
144
+ export async function submitContactForm(formData: FormData): Promise<void> {}
145
+ ````
146
+
147
+ ### React Component
148
+
149
+ ````typescript
150
+ /**
151
+ * Renders a responsive community card with name and location.
152
+ *
153
+ * @returns The community card JSX element
154
+ *
155
+ * @example
156
+ * ```tsx
157
+ * <CommunityCard community={community} className="mt-4" />
158
+ * ```
159
+ */
160
+ export function CommunityCard({ community, className }: CommunityCardProps) {
161
+ return <article className={className}>{community.name}</article>;
162
+ }
163
+ ````
164
+
165
+ ### Utility Function
166
+
167
+ ````typescript
168
+ /**
169
+ * Formats a date string for display.
170
+ *
171
+ * @param dateString - ISO 8601 date string
172
+ * @returns Human-readable date (e.g., "January 15, 2025")
173
+ *
174
+ * @example
175
+ * ```typescript
176
+ * formatDate("2025-01-15"); // "January 15, 2025"
177
+ * ```
178
+ */
179
+ export function formatDate(dateString: string): string {
180
+ return new Date(dateString).toLocaleDateString("en-US", { dateStyle: "long" });
181
+ }
182
+ ````
183
+
184
+ ### Generic Utility (`@typeParam`)
185
+
186
+ ````typescript
187
+ /**
188
+ * Returns the first element matching the predicate, or `undefined`.
189
+ *
190
+ * @typeParam T - The element type of the array
191
+ * @param items - The array to search
192
+ * @param predicate - Returns `true` for the desired element
193
+ * @returns The first matching element, or `undefined` if none match
194
+ */
195
+ export function findFirst<T>(items: T[], predicate: (item: T) => boolean): T | undefined {
196
+ return items.find(predicate);
197
+ }
198
+ ````
199
+
200
+ ## Forbidden Patterns → Fixes
201
+
202
+ | ❌ Forbidden (JSDoc) | ✅ TSDoc fix |
203
+ |----------------------|--------------|
204
+ | `@param {string} name` | `@param name` |
205
+ | `@returns {boolean} …` | `@returns …` |
206
+ | `@template T` | `@typeParam T` |
207
+ | `@module path/to/mod` | `@packageDocumentation` |
208
+ | `@fileoverview …` | `@packageDocumentation` |
209
+ | `@typedef {Object} X` | Define a TypeScript `interface`/`type` |
210
+ | `@callback X` | Define a TypeScript function type |
211
+ | `@type {X}` | Annotate the value: `const x: X = …` |
212
+ | `@property {T} name` | Inline `/** … */` on the interface member |
213
+ | `@function` / `@async` / `@class` / `@enum` | Remove — redundant with TypeScript |
214
+
215
+ ## Review Checklist
216
+
217
+ When reviewing doc comments, confirm:
218
+
219
+ - [ ] No `{type}` braces in `@param` / `@returns`.
220
+ - [ ] `@param name - Description` uses the hyphen and matches the actual parameter name.
221
+ - [ ] Generics use `@typeParam`, not `@template`.
222
+ - [ ] File-level docs use `@packageDocumentation` in the first comment, not `@module` / `@fileoverview`.
223
+ - [ ] Interface/type members are documented inline, not via `@property`.
224
+ - [ ] No redundant `@function` / `@async` / `@class` / `@enum` / `@typedef` / `@callback` / `@type`.
225
+ - [ ] `@param` / `@returns` are present only where they add real clarity.