@tsfpp/agents 1.0.3 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/README.md +5 -0
- package/bin/bootstrap.sh +103 -0
- package/copilot/agents/tsfpp-audit.agent.md +4 -3
- package/copilot/skills/boundary-api/SKILL.md +232 -0
- package/copilot/skills/coding-standard/SKILL.md +162 -0
- package/copilot/skills/prelude-api/SKILL.md +233 -0
- package/copilot/skills/react-coding-standard/SKILL.md +342 -0
- package/init.mjs +8 -2
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,24 @@ Versioning follows [Semantic Versioning](https://semver.org/).
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [1.1.1] - 2026-05-16
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- Added bootstrap shell script at `bin/bootstrap.sh`.
|
|
18
|
+
- Exposed bootstrap command in package metadata via `tsfpp-bootstrap`.
|
|
19
|
+
|
|
20
|
+
## [1.1.0] - 2026-05-16
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- Updated `init.mjs` to deploy Copilot reusable skills into `.github/skills/`.
|
|
25
|
+
- Added Copilot reusable skills to installer output:
|
|
26
|
+
- `copilot/skills/coding-standard/SKILL.md`
|
|
27
|
+
- `copilot/skills/prelude-api/SKILL.md`
|
|
28
|
+
- `copilot/skills/boundary-api/SKILL.md`
|
|
29
|
+
- `copilot/skills/react-coding-standard/SKILL.md`
|
|
30
|
+
|
|
13
31
|
## [1.0.3] - 2026-05-16
|
|
14
32
|
|
|
15
33
|
### Changed
|
package/README.md
CHANGED
|
@@ -50,6 +50,11 @@ node node_modules/@tsfpp/agents/init.mjs
|
|
|
50
50
|
prompts/
|
|
51
51
|
tsfpp-new-module.prompt.md
|
|
52
52
|
tsfpp-boundary-review.prompt.md
|
|
53
|
+
skills/
|
|
54
|
+
coding-standard/SKILL.md
|
|
55
|
+
prelude-api/SKILL.md
|
|
56
|
+
boundary-api/SKILL.md
|
|
57
|
+
react-coding-standard/SKILL.md
|
|
53
58
|
.claude/
|
|
54
59
|
CLAUDE.md ← Claude Code project context
|
|
55
60
|
```
|
package/bin/bootstrap.sh
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# tsfpp-bootstrap.sh — spin up a TSF++ sandbox in the current directory
|
|
3
|
+
# Usage: bash tsfpp-bootstrap.sh [project-name]
|
|
4
|
+
# If no name is given, the current directory is used as-is.
|
|
5
|
+
|
|
6
|
+
set -euo pipefail
|
|
7
|
+
|
|
8
|
+
# ── Colours ───────────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
GREEN='\033[0;32m'
|
|
11
|
+
DIM='\033[2m'
|
|
12
|
+
RESET='\033[0m'
|
|
13
|
+
|
|
14
|
+
ok() { echo -e "${GREEN}✓${RESET} $1"; }
|
|
15
|
+
dim() { echo -e "${DIM}$1${RESET}"; }
|
|
16
|
+
|
|
17
|
+
# ── Project directory ─────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
PROJECT_NAME="${1:-}"
|
|
20
|
+
|
|
21
|
+
if [[ -n "$PROJECT_NAME" ]]; then
|
|
22
|
+
mkdir -p "$PROJECT_NAME"
|
|
23
|
+
cd "$PROJECT_NAME"
|
|
24
|
+
ok "Created directory: $PROJECT_NAME"
|
|
25
|
+
fi
|
|
26
|
+
|
|
27
|
+
# ── 1. pnpm init ──────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
dim "Initialising package.json…"
|
|
30
|
+
pnpm init --yes > /dev/null
|
|
31
|
+
ok "pnpm init"
|
|
32
|
+
|
|
33
|
+
# ── 2. Install TSF++ ecosystem ────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
dim "Installing TSF++ packages (this may take a moment)…"
|
|
36
|
+
pnpm add -D \
|
|
37
|
+
typescript \
|
|
38
|
+
@tsfpp/standard \
|
|
39
|
+
@tsfpp/tsconfig \
|
|
40
|
+
@tsfpp/eslint-config \
|
|
41
|
+
@tsfpp/prelude \
|
|
42
|
+
@tsfpp/boundary \
|
|
43
|
+
@tsfpp/agents
|
|
44
|
+
ok "Installed TSF++ ecosystem"
|
|
45
|
+
|
|
46
|
+
# ── 3. tsconfig.json ──────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
dim "Writing tsconfig.json…"
|
|
49
|
+
cat > tsconfig.json << 'EOF'
|
|
50
|
+
{
|
|
51
|
+
"extends": "@tsfpp/tsconfig/app",
|
|
52
|
+
"compilerOptions": {
|
|
53
|
+
"rootDir": "src"
|
|
54
|
+
},
|
|
55
|
+
"include": ["src"]
|
|
56
|
+
}
|
|
57
|
+
EOF
|
|
58
|
+
ok "tsconfig.json"
|
|
59
|
+
|
|
60
|
+
# ── 4. eslint.config.js ───────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
dim "Writing eslint.config.js…"
|
|
63
|
+
cat > eslint.config.js << 'EOF'
|
|
64
|
+
import tsfpp from '@tsfpp/eslint-config'
|
|
65
|
+
export default tsfpp
|
|
66
|
+
EOF
|
|
67
|
+
ok "eslint.config.js"
|
|
68
|
+
|
|
69
|
+
# ── 5. package.json — type + scripts ─────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
dim "Patching package.json…"
|
|
72
|
+
npm pkg set type="module" --silent
|
|
73
|
+
npm pkg set scripts.typecheck="tsc --noEmit" --silent
|
|
74
|
+
npm pkg set scripts.lint="eslint src" --silent
|
|
75
|
+
npm pkg set scripts.check="pnpm typecheck && pnpm lint" --silent
|
|
76
|
+
ok "package.json scripts"
|
|
77
|
+
|
|
78
|
+
# ── 6. src/index.ts ───────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
dim "Creating src/index.ts…"
|
|
81
|
+
mkdir -p src
|
|
82
|
+
cat > src/index.ts << 'EOF'
|
|
83
|
+
// TSF++ sandbox — start here
|
|
84
|
+
// Import from @tsfpp/prelude to explore ADTs and combinators:
|
|
85
|
+
//
|
|
86
|
+
// import { ok, err, some, none, pipe, absurd } from '@tsfpp/prelude'
|
|
87
|
+
EOF
|
|
88
|
+
ok "src/index.ts"
|
|
89
|
+
|
|
90
|
+
# ── 7. Copilot agents (.github) ───────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
dim "Installing Copilot agents…"
|
|
93
|
+
node node_modules/@tsfpp/agents/init.mjs
|
|
94
|
+
ok "Copilot agents installed"
|
|
95
|
+
|
|
96
|
+
# ── 8. Done ───────────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
echo ""
|
|
99
|
+
echo -e "${GREEN}TSF++ sandbox ready.${RESET}"
|
|
100
|
+
echo ""
|
|
101
|
+
echo " pnpm check — typecheck + lint"
|
|
102
|
+
echo " code . — open in VS Code"
|
|
103
|
+
echo ""
|
|
@@ -132,7 +132,8 @@ Append each completed slice to the report:
|
|
|
132
132
|
- [x] 5.1 — Pipelines via `pipe` from prelude
|
|
133
133
|
- [x] 6.x — No `throw` in core
|
|
134
134
|
- [x] 7.x — JSDoc on all exports
|
|
135
|
-
- [x]
|
|
135
|
+
- [x] 8.x — Prefer prelude ADTs/constructors/helpers (no downstream reimplementation in domain code)
|
|
136
|
+
- [x] 9.x — Dependency hygiene (no deprecated dependencies, no banned imports per policy, no layer-violating imports)
|
|
136
137
|
|
|
137
138
|
#### Deviation register
|
|
138
139
|
|
|
@@ -146,10 +147,10 @@ Append each completed slice to the report:
|
|
|
146
147
|
## Focus-specific rule sets
|
|
147
148
|
|
|
148
149
|
### `types`
|
|
149
|
-
1.4 (no bare interface) · 1.5 (no `any`) · 1.6 (no `!` or `as`) · 3.x (readonly) · branded types on domain primitives · smart constructor completeness · exhaustive sum-type dispatch
|
|
150
|
+
1.4 (no bare interface) · 1.5 (no `any`) · 1.6 (no `!` or `as`) · 3.x (readonly) · branded types on domain primitives · smart constructor completeness · exhaustive sum-type dispatch · prelude ADT/constructor/helper reuse (no downstream reimplementation)
|
|
150
151
|
|
|
151
152
|
### `boundary`
|
|
152
|
-
API_CODING_STANDARD.md Rules 1–5 · Zod schema completeness · Result/Option at I/O · `extractContext` usage · `apiErrorToResponse` coverage · no raw `throw` across boundaries · `@tsfpp/boundary` response builders used
|
|
153
|
+
API_CODING_STANDARD.md Rules 1–5 · Zod schema completeness · Result/Option at I/O · `extractContext` usage · `apiErrorToResponse` coverage · no raw `throw` across boundaries · `@tsfpp/boundary` response builders used · avoid boundary-local ADT/helper reinvention when prelude equivalents exist
|
|
153
154
|
|
|
154
155
|
### `complexity`
|
|
155
156
|
Function body ≤ 40 lines · cyclomatic complexity ≤ 10 · nesting ≤ 4 · arity ≤ 3 positional params · pipeline depth ≤ 8 stages
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: boundary-api
|
|
3
|
+
description: >
|
|
4
|
+
Complete API surface of @tsfpp/boundary: typed request context, RFC 9457 error
|
|
5
|
+
responses, ApiError taxonomy, response builders, cursor pagination, long-running
|
|
6
|
+
operations, bulk operations, idempotency, observability middleware, webhook
|
|
7
|
+
signing, rate-limit headers, CORS, and cache policy. Load when writing or
|
|
8
|
+
reviewing any HTTP handler that imports from @tsfpp/boundary, or when choosing
|
|
9
|
+
between response builders, error mappers, or middleware composition patterns.
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# @tsfpp/boundary API
|
|
13
|
+
|
|
14
|
+
Framework-agnostic Fetch API primitives. One peer dependency: `@tsfpp/prelude`.
|
|
15
|
+
`kind` is the discriminant for all ADTs in this module.
|
|
16
|
+
|
|
17
|
+
## Import path
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { extractContext, apiErrorToResponse, ... } from '@tsfpp/boundary';
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Core exports by group
|
|
26
|
+
|
|
27
|
+
### Branded primitives
|
|
28
|
+
|
|
29
|
+
| Type | Smart constructor | Constraint |
|
|
30
|
+
|---|---|---|
|
|
31
|
+
| `TraceId` | `mkTraceId(raw)` → `Option<TraceId>` | any non-empty string |
|
|
32
|
+
| `PrincipalId` | `mkPrincipalId(raw)` → `Option<PrincipalId>` | any non-empty string |
|
|
33
|
+
| `Cursor` | internal — use `encodeCursor` | never construct directly |
|
|
34
|
+
| `IdempotencyKey` | `mkIdempotencyKey(raw)` → `Option<IdempotencyKey>` | `[A-Za-z0-9_-]{1,255}` |
|
|
35
|
+
| `WebhookEventId` | `mkWebhookEventId(raw)` → `Option<WebhookEventId>` | any non-empty string |
|
|
36
|
+
|
|
37
|
+
### Request context
|
|
38
|
+
|
|
39
|
+
| Export | Description |
|
|
40
|
+
|---|---|
|
|
41
|
+
| `RequestContext` | `{ traceId, principalId: Option<PrincipalId>, idempotencyKey: Option<IdempotencyKey>, method, url, routeTemplate }` |
|
|
42
|
+
| `extractContext(req, routeTemplate)` | Call at the top of every handler. Reads `traceparent` / `x-request-id` / `x-trace-id`; generates UUID fallback. `routeTemplate` must be the parameterised path (`/v1/tracks/:id`), never the resolved URL. |
|
|
43
|
+
| `extractTraceId(req)` | Reads trace headers only; generates UUID fallback. |
|
|
44
|
+
|
|
45
|
+
### Validation
|
|
46
|
+
|
|
47
|
+
| Export | Description |
|
|
48
|
+
|---|---|
|
|
49
|
+
| `ValidationError` | `{ kind: 'validation'; message: string; issues: ReadonlyArray<FieldIssue> }` |
|
|
50
|
+
| `FieldIssue` | `{ field: string; message: string }` |
|
|
51
|
+
| `fromZodError(zodError)` | Lifts a `ZodError` into a `ValidationError`. Use after `safeParse`. |
|
|
52
|
+
| `mkValidationError(message, issues?)` | Manual construction for non-Zod sources. |
|
|
53
|
+
|
|
54
|
+
### `ApiError` taxonomy
|
|
55
|
+
|
|
56
|
+
Discriminated union on `kind`. Pass to `apiErrorToResponse`; never construct `ProblemDetails` manually for these variants.
|
|
57
|
+
|
|
58
|
+
| `kind` | Extra fields | HTTP |
|
|
59
|
+
|---|---|---|
|
|
60
|
+
| `validation` | `message`, `issues: ReadonlyArray<FieldIssue>` | 422 |
|
|
61
|
+
| `not_found` | `resource: string`, `id: string` | 404 |
|
|
62
|
+
| `conflict` | `detail: string` | 409 |
|
|
63
|
+
| `permission` | `required: string` | 403 |
|
|
64
|
+
| `unauthenticated` | — | 401 + `WWW-Authenticate` |
|
|
65
|
+
| `rate_limit` | `retryAfterSeconds?: number` | 429 + `Retry-After` |
|
|
66
|
+
| `precondition` | `detail: string` | 412 |
|
|
67
|
+
| `gone` | `resource: string` | 410 |
|
|
68
|
+
| `dependency` | `dependency: string`, `cause: unknown` | 502 — **log `cause` before calling mapper** |
|
|
69
|
+
| `internal` | `cause: unknown` | 500 — **log `cause` before calling mapper** |
|
|
70
|
+
|
|
71
|
+
### Problem Details (RFC 9457)
|
|
72
|
+
|
|
73
|
+
| Export | Description |
|
|
74
|
+
|---|---|
|
|
75
|
+
| `ProblemDetails` | `{ type, title, status, code, detail?, instance?, traceId, errors? }` |
|
|
76
|
+
| `mkProblem(status, code, title, traceId, opts?)` | Constructs a `ProblemDetails`. `type` defaults to `'about:blank'`. |
|
|
77
|
+
| `problemResponse(problem, headers?)` | `Response` with `Content-Type: application/problem+json`. |
|
|
78
|
+
|
|
79
|
+
### Response builders
|
|
80
|
+
|
|
81
|
+
| Export | Status | Notes |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| `okResponse(body, headers?)` | 200 | |
|
|
84
|
+
| `createdResponse(body, location, headers?)` | 201 | Sets `Location` header |
|
|
85
|
+
| `acceptedResponse(operation, location, headers?)` | 202 | LRO — sets `Location` header |
|
|
86
|
+
| `noContentResponse(headers?)` | 204 | No body — use after mutations with no return value |
|
|
87
|
+
| `redirectResponse(status, location, headers?)` | 301/302/307/308 | Prefer 308 over 301, 307 over 302 |
|
|
88
|
+
| `jsonResponse(status, body, headers?)` | any | Fallback when the above don't fit |
|
|
89
|
+
|
|
90
|
+
### Error mapping
|
|
91
|
+
|
|
92
|
+
| Export | Description |
|
|
93
|
+
|---|---|
|
|
94
|
+
| `apiErrorToProblem(error, ctx)` | `ApiError` → `ProblemDetails`. Exhaustive; never leaks `cause`. |
|
|
95
|
+
| `apiErrorToResponse(error, ctx)` | `ApiError` → `Response`. Adds `WWW-Authenticate` on `unauthenticated`, `Retry-After` on `rate_limit`. **Prefer this over manual `problemResponse`**. |
|
|
96
|
+
| `ErrorMapper<E>` | `(error: E, ctx: RequestContext) => Response` — implement for app-specific variants; delegate canonical variants to `apiErrorToResponse`. |
|
|
97
|
+
|
|
98
|
+
### Pagination
|
|
99
|
+
|
|
100
|
+
| Export | Description |
|
|
101
|
+
|---|---|
|
|
102
|
+
| `Paginated<T>` | `{ items: ReadonlyArray<T>; nextCursor: Cursor \| null; totalCount: number \| null }` |
|
|
103
|
+
| `PageQuery` | `{ limit: number; cursor: Cursor \| null }` |
|
|
104
|
+
| `mkPaginated(items, nextCursor, totalCount?)` | Constructs a `Paginated<T>` body. `totalCount` — return `null` unless precomputed. |
|
|
105
|
+
| `parsePaginationQuery(url, maxLimit?)` | Validates `limit` and `cursor` from URL query string → `Result<PageQuery, ValidationError>` |
|
|
106
|
+
| `encodeCursor(payload)` | Record → opaque `Cursor` (base64url) |
|
|
107
|
+
| `decodeCursor(cursor)` | `Cursor` → `Option<Record<string, unknown>>` |
|
|
108
|
+
|
|
109
|
+
### Long-running operations
|
|
110
|
+
|
|
111
|
+
`Operation<T>` is a discriminated union on `kind`: `running | succeeded | failed | cancelled`.
|
|
112
|
+
|
|
113
|
+
| Export | Description |
|
|
114
|
+
|---|---|
|
|
115
|
+
| `mkRunningOp(operationId, progress?)` | `progress` 0–100 |
|
|
116
|
+
| `mkSucceededOp(operationId, result, createdAt)` | |
|
|
117
|
+
| `mkFailedOp(operationId, error, createdAt)` | |
|
|
118
|
+
| `mkCancelledOp(operationId, createdAt)` | |
|
|
119
|
+
|
|
120
|
+
Return `acceptedResponse(op, pollUrl)` from the trigger handler; return `okResponse(op)` from the polling handler.
|
|
121
|
+
|
|
122
|
+
### Bulk operations
|
|
123
|
+
|
|
124
|
+
| Export | Description |
|
|
125
|
+
|---|---|
|
|
126
|
+
| `BulkItem<T>` | `ok` (200/201) or `error` (4xx/5xx) variant |
|
|
127
|
+
| `BulkResponse<T>` | `{ items: ReadonlyArray<BulkItem<T>> }` |
|
|
128
|
+
| `mkBulkOkItem(body, status?)` | Successful item |
|
|
129
|
+
| `mkBulkErrorItem(problem)` | Failed item from `ProblemDetails` |
|
|
130
|
+
| `bulkResponse(items)` | `207 Multi-Status` |
|
|
131
|
+
|
|
132
|
+
### Rate limiting
|
|
133
|
+
|
|
134
|
+
| Export | Description |
|
|
135
|
+
|---|---|
|
|
136
|
+
| `RateLimitState` | `{ limit: number; remaining: number; resetAt: Date }` |
|
|
137
|
+
| `rateLimitHeaders(state)` | `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` — attach to **all** responses on rate-limited endpoints, not just 429s |
|
|
138
|
+
| `retryAfterHeader(seconds)` | `Retry-After` — add on 429 in addition to `rateLimitHeaders` |
|
|
139
|
+
|
|
140
|
+
### Security and CORS
|
|
141
|
+
|
|
142
|
+
| Export | Description |
|
|
143
|
+
|---|---|
|
|
144
|
+
| `baselineSecurityHeaders` | `HSTS`, `CSP`, `Referrer-Policy`, `X-Content-Type-Options`, `X-Frame-Options`, `Cache-Control: no-store` — merge into every response |
|
|
145
|
+
| `corsHeaders(allowedOrigins, requestOrigin, opts?)` | Never reflects `Origin` blindly. Returns `{}` for unlisted origins. Always sets `Vary: Origin`. `allowedOrigins` comes from config, never from headers. |
|
|
146
|
+
|
|
147
|
+
### Idempotency
|
|
148
|
+
|
|
149
|
+
| Export | Description |
|
|
150
|
+
|---|---|
|
|
151
|
+
| `IdempotencyStore` | Port — `check`, `markInFlight`, `store`. Implement with Redis / Postgres / any durable store. |
|
|
152
|
+
| `IdempotencyLookup` | Union — `first_request \| replay \| in_flight \| key_conflict` |
|
|
153
|
+
| `StoredResponse` | Serialisable response snapshot for replay |
|
|
154
|
+
| `withIdempotency(store)` | `(RawHandler) → RawHandler` HOF — full lifecycle |
|
|
155
|
+
|
|
156
|
+
### Observability
|
|
157
|
+
|
|
158
|
+
| Export | Description |
|
|
159
|
+
|---|---|
|
|
160
|
+
| `RequestLogger` | Port — `info(entry)`, `error(entry)`. Implement with pino, winston, etc. |
|
|
161
|
+
| `RequestLog` | Structured log entry type |
|
|
162
|
+
| `withRequestLog(logger, routeTemplate)` | `(RawHandler) → RawHandler` HOF — one entry per request |
|
|
163
|
+
|
|
164
|
+
### Webhooks
|
|
165
|
+
|
|
166
|
+
| Export | Description |
|
|
167
|
+
|---|---|
|
|
168
|
+
| `signWebhook(secret, id, body)` | HMAC-SHA256 over `{timestamp}.{body}` → `WebhookSignatureHeaders` |
|
|
169
|
+
| `verifyWebhook(secret, headers, body, maxAge?)` | Verifies signature + timestamp recency (default 5 min). Constant-time comparison. |
|
|
170
|
+
| `WebhookSignatureHeaders` | `x-webhook-id`, `x-webhook-timestamp`, `x-webhook-signature` |
|
|
171
|
+
|
|
172
|
+
### Cache headers
|
|
173
|
+
|
|
174
|
+
| Export | Description |
|
|
175
|
+
|---|---|
|
|
176
|
+
| `CachePolicy` | `'no-store' \| 'private-revalidate' \| 'public-short' \| 'public-long' \| 'immutable'` |
|
|
177
|
+
| `cacheHeaders(policy, etag?)` | Builds appropriate `Cache-Control` / `ETag` headers |
|
|
178
|
+
|
|
179
|
+
### Handler types
|
|
180
|
+
|
|
181
|
+
| Export | Description |
|
|
182
|
+
|---|---|
|
|
183
|
+
| `RawHandler` | `(req: Request) => Promise<Response>` |
|
|
184
|
+
| `HandlerFactory<Deps>` | `(deps: Deps) => RawHandler` — canonical factory shape |
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## Canonical handler shape
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
export const createTrackHandler: HandlerFactory<Deps> = (deps) => async (req) => {
|
|
192
|
+
const ctx = extractContext(req, '/v1/tracks'); // 1. context first
|
|
193
|
+
|
|
194
|
+
const raw = await req.json().catch(() => null);
|
|
195
|
+
const parsed = schema.safeParse(raw);
|
|
196
|
+
if (!parsed.success) return apiErrorToResponse(fromZodError(parsed.error), ctx); // 2. validate
|
|
197
|
+
|
|
198
|
+
const result = await deps.tracks.create(parsed.data); // 3. use case
|
|
199
|
+
if (isErr(result)) return apiErrorToResponse(result.error, ctx);
|
|
200
|
+
|
|
201
|
+
return createdResponse(result.value, `/v1/tracks/${result.value.id}`, {
|
|
202
|
+
'X-Request-Id': ctx.traceId,
|
|
203
|
+
}); // 4. respond
|
|
204
|
+
};
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
## Middleware composition via `pipe`
|
|
208
|
+
|
|
209
|
+
Compose outermost-last. `withRequestLog` must be outermost so it captures every outcome including idempotency replays.
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
const handler: RawHandler = pipe(
|
|
213
|
+
createTrackHandler(deps), // innermost: business logic
|
|
214
|
+
withIdempotency(idempotencyStore), // middle: replay / in-flight guard
|
|
215
|
+
withRequestLog(logger, '/v1/tracks'), // outermost: structured log
|
|
216
|
+
);
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
## Custom error extension pattern
|
|
220
|
+
|
|
221
|
+
```ts
|
|
222
|
+
type AppError = ApiError | QuotaExceededError;
|
|
223
|
+
|
|
224
|
+
const appErrorToResponse: ErrorMapper<AppError> = (error, ctx) => {
|
|
225
|
+
switch (error.kind) {
|
|
226
|
+
case 'quota_exceeded':
|
|
227
|
+
return problemResponse(mkProblem(429, 'quota_exceeded', '...', ctx.traceId));
|
|
228
|
+
default:
|
|
229
|
+
return apiErrorToResponse(error, ctx); // delegate canonical variants
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
```
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: coding-standard
|
|
3
|
+
description: >
|
|
4
|
+
Normative TSF++ coding rules for all TypeScript in this repository. Load when
|
|
5
|
+
writing, reviewing, or auditing any TypeScript file: enforces forbidden
|
|
6
|
+
constructs, hard rules by rule number, layer-specific constraints (core, api,
|
|
7
|
+
dal, react, cli), discriminant conventions (_tag vs kind), deviation procedure,
|
|
8
|
+
and size limits. Supersedes general TypeScript conventions wherever they
|
|
9
|
+
conflict.
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# TSF++ coding standard — v1.1.0
|
|
13
|
+
|
|
14
|
+
Standard version: 1.1.0 (2026-05-15). When this skill and the full `CODING_STANDARD.md` conflict, the file wins.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Never (MUST NOT — all layers)
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
class · this · new · instanceof · namespace · prototype inheritance
|
|
22
|
+
enum → use string literal unions or `as const` objects
|
|
23
|
+
interface → use `type`; deviation requires // DEVIATION(1.4): <reason>
|
|
24
|
+
any → use `unknown` at I/O boundaries and narrow in scope
|
|
25
|
+
as → only inside a smart constructor body after validation
|
|
26
|
+
! → non-null assertion forbidden everywhere
|
|
27
|
+
let · var
|
|
28
|
+
for · while · do..while
|
|
29
|
+
push · pop · splice · sort · reverse · fill · copyWithin (mutating methods)
|
|
30
|
+
property assignment · delete
|
|
31
|
+
throw → only at adapter boundaries (Rule 6.2); core uses Result<T,E>
|
|
32
|
+
== != → use === !==
|
|
33
|
+
truthiness checks on non-booleans (if (str) · if (value))
|
|
34
|
+
optional params ? → use Option<T> or a defaults record
|
|
35
|
+
default: in an exhaustive switch → use absurd(x)
|
|
36
|
+
direct _tag comparison outside @tsfpp/prelude → use exported guards
|
|
37
|
+
import from 'ramda' → use @tsfpp/prelude
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Hard rules (all layers)
|
|
43
|
+
|
|
44
|
+
| Rule | Level | Constraint |
|
|
45
|
+
|------|-------|-----------|
|
|
46
|
+
| 1.1 | MUST | Sum types: tagged discriminated union with a literal discriminant |
|
|
47
|
+
| 1.2 | MUST | Exhaustive `switch` ends in `default: return absurd(x)` |
|
|
48
|
+
| 1.3 | MUST | Nominal distinctions via branded types; only smart constructors (`mk*`, `from*`, `as*`) |
|
|
49
|
+
| 1.4 | MUST | `type` aliases; `interface` requires `// DEVIATION(1.4): <reason>` |
|
|
50
|
+
| 1.5 | MUST | No `any`; `unknown` at I/O boundaries, narrowed in scope |
|
|
51
|
+
| 1.6 | MUST | No `!`; no `as` outside smart constructor bodies |
|
|
52
|
+
| 1.8 | MUST | No `enum`; string literal unions or `as const` |
|
|
53
|
+
| 1.9 | MUST | No `class` · `this` · `new` · `instanceof` · `namespace` |
|
|
54
|
+
| 1.11 | MUST | Access prelude ADT discriminants via exported guards only (`isOk`, `isSome`, …) |
|
|
55
|
+
| 1.12 | MUST | Discriminant convention: `_tag` for prelude/library ADTs · `kind` for domain ADTs |
|
|
56
|
+
| 2.1 | MUST | `const` only; no `let`/`var` |
|
|
57
|
+
| 2.2 | MUST | `ReadonlyArray<T>` everywhere |
|
|
58
|
+
| 2.3 | MUST | No mutating methods or property assignment |
|
|
59
|
+
| 2.5 | MUST | `as const` for literal narrowing and config tables |
|
|
60
|
+
| 3.x | MUST | `readonly` on every record field |
|
|
61
|
+
| 4.1 | MUST | Every sum-type `switch` exhaustive; `default: return absurd(x)` |
|
|
62
|
+
| 4.5 | MUST | Strict equality only; no truthiness on non-booleans |
|
|
63
|
+
| 5.1 | MUST | Pipelines via `pipe` from `@tsfpp/prelude` |
|
|
64
|
+
| 6.2 | MUST | `throw` only in adapter boundaries; wrap with `tryCatch`/`tryCatchAsync` |
|
|
65
|
+
| 6.3 | MUST | No `null`/`undefined` propagation; use `Option<A>` |
|
|
66
|
+
| 6.6 | MUST | Prefer `Promise.allSettled` over `Promise.all` when partial failure is meaningful |
|
|
67
|
+
| 7.x | MUST | JSDoc on every exported symbol; algebraic laws for combinators |
|
|
68
|
+
| 8.4 | MUST | Parse, don't validate: convert `unknown` to domain types at the boundary |
|
|
69
|
+
| 9.6 | MUST | Pre-commit hooks enforce lint and typecheck |
|
|
70
|
+
| 11.1 | MUST | One type, one file; related constructors co-located |
|
|
71
|
+
| 11.2 | MUST | File ≤ 400 lines; 800 absolute maximum with deviation |
|
|
72
|
+
|
|
73
|
+
**Size limits:** function body ≤ 40 lines · cyclomatic complexity ≤ 10 · nesting depth ≤ 4.
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Discriminant convention (Rule 1.12)
|
|
78
|
+
|
|
79
|
+
| ADT origin | Discriminant field | Example |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| `@tsfpp/prelude` (Result, Option) | `_tag` | `{ _tag: 'Ok'; value: A }` |
|
|
82
|
+
| Domain code | `kind` | `{ kind: 'pending'; orderId: OrderId }` |
|
|
83
|
+
|
|
84
|
+
Access prelude ADTs through exported guards only — never `result._tag === 'Ok'`.
|
|
85
|
+
`_tag` in a `switch` is permitted only when exhaustiveness via `absurd` requires it.
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## Key idioms
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
// Sum type — domain ADT (kind)
|
|
93
|
+
type OrderStatus =
|
|
94
|
+
| { readonly kind: 'pending'; readonly orderId: OrderId }
|
|
95
|
+
| { readonly kind: 'fulfilled'; readonly orderId: OrderId; readonly at: Date }
|
|
96
|
+
|
|
97
|
+
// Exhaustive match
|
|
98
|
+
const label = (s: OrderStatus): string => {
|
|
99
|
+
switch (s.kind) {
|
|
100
|
+
case 'pending': return 'Pending'
|
|
101
|
+
case 'fulfilled': return 'Fulfilled'
|
|
102
|
+
default: return absurd(s)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Branded type — smart constructor only
|
|
107
|
+
type TrackId = Brand<string, 'TrackId'>
|
|
108
|
+
const mkTrackId = (raw: string): Option<TrackId> =>
|
|
109
|
+
raw.length > 0
|
|
110
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- DEVIATION(1.6): smart-constructor body
|
|
111
|
+
? some(raw as TrackId)
|
|
112
|
+
: none
|
|
113
|
+
|
|
114
|
+
// Deviation marker
|
|
115
|
+
// DEVIATION(1.4): third-party plugin augmentation requires interface
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## Deviation procedure
|
|
121
|
+
|
|
122
|
+
Every MUST violation requires all three:
|
|
123
|
+
1. Inline comment: `// DEVIATION(N.M): <one-line justification>`
|
|
124
|
+
2. At least one reviewer sign-off
|
|
125
|
+
3. Entry in `DEVIATIONS.md` for project-wide deviations
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## Layer constraints
|
|
130
|
+
|
|
131
|
+
### `core`
|
|
132
|
+
- Zero framework imports. Zero I/O. Zero effects. Zero `Promise` in signatures.
|
|
133
|
+
- Domain types only: sum types, product types, branded types, smart constructors.
|
|
134
|
+
- No `@tsfpp/boundary`, no `process`, `fs`, `fetch`.
|
|
135
|
+
|
|
136
|
+
### `api`
|
|
137
|
+
- All input parsed with Zod at the boundary; lifted into `Result<A, ZodError>`.
|
|
138
|
+
- Handlers return `Promise<Response>` via `@tsfpp/boundary` response builders.
|
|
139
|
+
- Errors mapped through `apiErrorToResponse`; never raw `throw`.
|
|
140
|
+
- Context extracted via `extractContext`; never read raw headers in business logic.
|
|
141
|
+
- Handler shape: parse → domain map → use-case call → response map. Nothing else.
|
|
142
|
+
- Architecture: transport → boundary → use-case → domain core → adapters. One-way only.
|
|
143
|
+
|
|
144
|
+
### `dal`
|
|
145
|
+
- Adapter pattern: implement a port (interface) defined by the domain.
|
|
146
|
+
- Wrap all third-party calls in `tryCatchAsync` from `@tsfpp/prelude`.
|
|
147
|
+
- Map infrastructure errors to typed domain error ADTs before returning.
|
|
148
|
+
- No domain logic. No HTTP semantics. Pure data translation.
|
|
149
|
+
|
|
150
|
+
### `react`
|
|
151
|
+
- Component state as discriminated union with `kind` discriminant; never boolean soup.
|
|
152
|
+
- Data fetching via TanStack Query; no raw `useEffect` for fetching.
|
|
153
|
+
- `useEffect` only for genuine external synchronisation; requires an explanatory comment.
|
|
154
|
+
- Props as `readonly` record; no optional props — use `Option<T>`.
|
|
155
|
+
- Components are pure render functions; side effects are isolated.
|
|
156
|
+
- Prop drilling limit: 2 levels; extract context or compose instead.
|
|
157
|
+
|
|
158
|
+
### `cli`
|
|
159
|
+
- `process.argv` parsed at the entry point boundary only; typed `Args` ADT internally.
|
|
160
|
+
- `process.exit` only at the outermost boundary after all async work resolves.
|
|
161
|
+
- Errors as `Result<T, E>`; convert to exit codes only at the shell boundary.
|
|
162
|
+
- No `console.log` in core — use a `Logger` port.
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: prelude-api
|
|
3
|
+
description: >
|
|
4
|
+
Provides the complete API surface of @tsfpp/prelude: all exported combinators,
|
|
5
|
+
ADTs, and utility functions for Option, Result, List, ReadonlyMap, ReadonlySet,
|
|
6
|
+
branded types, and record decoding. Load this skill when writing or reviewing
|
|
7
|
+
TypeScript that imports from @tsfpp/prelude, when choosing between
|
|
8
|
+
map/flatMap/traverseArray, when constructing or querying ReadonlyMap or
|
|
9
|
+
ReadonlySet, when decoding unknown runtime values, or when deciding between
|
|
10
|
+
pipe and flow.
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# @tsfpp/prelude API
|
|
14
|
+
|
|
15
|
+
All combinators are curried data-last and compose with `pipe`.
|
|
16
|
+
|
|
17
|
+
## Import path
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { pipe, ok, err, some, none, ... } from '@tsfpp/prelude';
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Never import from `ramda` directly. Never import sub-paths.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Core exports
|
|
28
|
+
|
|
29
|
+
| Group | Exports |
|
|
30
|
+
|---|---|
|
|
31
|
+
| Combinators | `pipe`, `flow`, `comp`, `complement` |
|
|
32
|
+
| Exhaustiveness | `absurd` |
|
|
33
|
+
| Option | `some`, `none`, `isSome`, `isNone`, `mapO`, `flatMapO`, `orElse`, `getOrElse` |
|
|
34
|
+
| Result | `ok`, `err`, `isOk`, `isErr`, `map`, `flatMap`, `flatMapAsync`, `tryCatch`, `tryCatchAsync`, `tap`, `tapErr` |
|
|
35
|
+
| Unit | `unit`, `Unit` |
|
|
36
|
+
| Guards / conversions | `fromNullable`, `toNullable`, `isRecord`, `fromUnknownString`, `fromUnknownArray`, `fromUnknownArrayOf`, `fromNonEmptyString` |
|
|
37
|
+
| Record decoding | `UnknownRecord`, `getStringField`, `getNumberField`, `getBooleanField`, `getTypedField` |
|
|
38
|
+
| Branded types | `Brand`, `Every`, `Any`, `mkEvery`, `mkAny` |
|
|
39
|
+
| Collections | `traverseArray`, `traverseArrayO`, `sequenceArrayO`, `unique` |
|
|
40
|
+
| List ADT | `List`, `nil`, `cons`, `singletonList`, `isCons`, `isNil`, `fromArray`, `toArray`, `headList`, `tailList`, `isEmptyList`, `lengthList`, `mapList`, `flatMapList`, `appendList`, `reverseList`, `filterList`, `foldList`, `foldLeftList`, `foldLeftListCurried`, `traverseList` |
|
|
41
|
+
| ReadonlyMap | `intoMap`, `entriesOfMap`, `assoc`, `dissoc`, `lookup` |
|
|
42
|
+
| ReadonlySet | `intoSet`, `conj`, `disj`, `member` |
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Decision rules
|
|
47
|
+
|
|
48
|
+
### `map` vs `flatMap`
|
|
49
|
+
|
|
50
|
+
- Transformation **cannot fail** → `map` / `mapO`
|
|
51
|
+
- Transformation **can fail or be absent** → `flatMap` / `flatMapO`
|
|
52
|
+
- Mismatching produces `Result<Result<T,E>,E>` or `Option<Option<T>>` — always wrong.
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
const upper = map((s: string) => s.toUpperCase())(name); // cannot fail
|
|
56
|
+
const valid = flatMap(validateEmail)(input); // can fail
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### `orElse` vs `getOrElse`
|
|
60
|
+
|
|
61
|
+
- Keep `Option` context → `orElse(() => some(fallback))`
|
|
62
|
+
- Collapse to concrete value → `getOrElse(() => fallback)`
|
|
63
|
+
|
|
64
|
+
### `pipe` vs `flow`
|
|
65
|
+
|
|
66
|
+
- Initial value is at hand → `pipe(value, f, g, h)`
|
|
67
|
+
- Named or reusable pipeline → `flow(f, g, h)` — returns a function
|
|
68
|
+
- `flow(f, g, h)(x) ≡ pipe(x, f, g, h)`
|
|
69
|
+
|
|
70
|
+
### `tap` vs `tapErr`
|
|
71
|
+
|
|
72
|
+
Use for side effects (logging, metrics). Neither changes the value.
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
pipe(
|
|
76
|
+
parseInput(raw),
|
|
77
|
+
tap((v) => log.debug({ parsed: v })),
|
|
78
|
+
flatMap(validate),
|
|
79
|
+
tapErr((e) => log.warn({ error: e })),
|
|
80
|
+
)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Never break the chain to log. Always use `tap`/`tapErr`.
|
|
84
|
+
|
|
85
|
+
### `Result<Unit, E>` for success-only operations
|
|
86
|
+
|
|
87
|
+
Use `ok(unit)` when an operation succeeds but produces no value (write, save, dispatch). Never use `Result<void, E>`.
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
import { ok, err, unit, type Result, type Unit } from '@tsfpp/prelude';
|
|
91
|
+
|
|
92
|
+
const save = (x: Foo): Result<Unit, SaveError> =>
|
|
93
|
+
persist(x) ? ok(unit) : err({ code: 'WRITE_FAILED' });
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### `absurd` for exhaustive matching
|
|
97
|
+
|
|
98
|
+
Switch on `_tag` only in exhaustive `switch`. Use exported guards (`isOk`, `isSome`) everywhere else.
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
switch (result._tag) {
|
|
102
|
+
case 'Ok': return result.value;
|
|
103
|
+
case 'Err': return result.error;
|
|
104
|
+
default: return absurd(result);
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## Boundary patterns
|
|
111
|
+
|
|
112
|
+
### Wrapping throwing third-party code
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
const parsed = tryCatch(
|
|
116
|
+
() => JSON.parse(raw),
|
|
117
|
+
(e) => `parse failed: ${String(e)}`,
|
|
118
|
+
);
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Never use raw `try/catch` inside your own code. Return `Result` directly.
|
|
122
|
+
|
|
123
|
+
### Lifting Zod into `Result`
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
const fromZod =
|
|
127
|
+
<A>(schema: ZodSchema<A>) =>
|
|
128
|
+
(raw: unknown): Result<A, ZodError> => {
|
|
129
|
+
const r = schema.safeParse(raw);
|
|
130
|
+
return r.success ? ok(r.data) : err(r.error);
|
|
131
|
+
};
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Zod owns the boundary. The prelude owns everything after.
|
|
135
|
+
|
|
136
|
+
### Decoding `unknown` records
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
import { isRecord, getStringField, getNumberField, isSome, type UnknownRecord } from '@tsfpp/prelude';
|
|
140
|
+
|
|
141
|
+
const decode = (raw: unknown): Result<Foo, string> => {
|
|
142
|
+
if (!isRecord(raw)) return err('not an object');
|
|
143
|
+
const name = getStringField(raw, 'name'); // Option<string> — rejects empty/whitespace
|
|
144
|
+
const age = getNumberField(raw, 'age'); // Option<number> — rejects NaN/Infinity
|
|
145
|
+
return isSome(name) && isSome(age)
|
|
146
|
+
? ok({ name: name.value, age: age.value })
|
|
147
|
+
: err('missing fields');
|
|
148
|
+
};
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
For domain types, use `getTypedField` with a runtime guard:
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
const userId = getTypedField(payload, 'userId', isUserId); // Option<UserId>
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## Array patterns
|
|
160
|
+
|
|
161
|
+
### `traverseArray` — map a fallible function, short-circuit on first `Err`
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
const all = traverseArray(parseFoo)(rawItems); // Result<ReadonlyArray<Foo>, E>
|
|
165
|
+
// Never: rawItems.map(parseFoo) — produces ReadonlyArray<Result<Foo,E>>
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### `traverseArrayO` / `sequenceArrayO` — collect only if every element is `Some`
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
traverseArrayO(fromNullable)([1, 2, 3]); // Some([1, 2, 3])
|
|
172
|
+
traverseArrayO(fromNullable)([1, null, 3]); // None
|
|
173
|
+
|
|
174
|
+
// Already have ReadonlyArray<Option<A>>? Use sequenceArrayO directly:
|
|
175
|
+
sequenceArrayO([some(1), some(2)]); // Some([1, 2])
|
|
176
|
+
sequenceArrayO([some(1), none]); // None
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### `fromUnknownArrayOf` — guard typed arrays from unknown
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
const strings = fromUnknownArrayOf(
|
|
183
|
+
(v): v is string => typeof v === 'string'
|
|
184
|
+
)(raw); // Option<ReadonlyArray<string>>
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## ReadonlyMap combinators
|
|
190
|
+
|
|
191
|
+
Always construct maps with `intoMap`. Never call `new Map()` directly.
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
import { intoMap, entriesOfMap, assoc, dissoc, lookup } from '@tsfpp/prelude';
|
|
195
|
+
|
|
196
|
+
const m = intoMap([['a', 1], ['b', 2]]); // ReadonlyMap<string, number>
|
|
197
|
+
const v = pipe(m, lookup('a')); // Some(1)
|
|
198
|
+
const m2 = pipe(m, assoc('c', 3)); // insert or replace
|
|
199
|
+
const m3 = pipe(m2, dissoc('a')); // remove
|
|
200
|
+
const es = entriesOfMap(m); // ReadonlyArray<readonly [string, number]>
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## ReadonlySet combinators
|
|
206
|
+
|
|
207
|
+
Always construct sets with `intoSet`. Never call `new Set()` directly.
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
import { intoSet, conj, disj, member } from '@tsfpp/prelude';
|
|
211
|
+
|
|
212
|
+
const s = intoSet([1, 2, 2, 3]); // ReadonlySet<number> — {1, 2, 3}
|
|
213
|
+
const s2 = pipe(s, conj(4)); // add
|
|
214
|
+
const s3 = pipe(s2, disj(2)); // remove (no-ops when absent)
|
|
215
|
+
const has = pipe(s, member(1)); // true
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
---
|
|
219
|
+
|
|
220
|
+
## `List` vs `ReadonlyArray`
|
|
221
|
+
|
|
222
|
+
Use `List` when: prepend-heavy (`cons` is O(1)), structurally recursive, pattern-matching on head/tail.
|
|
223
|
+
Use `ReadonlyArray` when: random access, interop with APIs, append-heavy.
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
import { fromArray, toArray, isCons, isNil } from '@tsfpp/prelude';
|
|
227
|
+
|
|
228
|
+
const asList = fromArray(rawArray);
|
|
229
|
+
// ... structural processing with isCons / isNil guards ...
|
|
230
|
+
const result = toArray(asList);
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Fold operations: `foldList` (right-associative), `foldLeftList` / `foldLeftListCurried` (left-associative).
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: react-coding-standard
|
|
3
|
+
description: >
|
|
4
|
+
Normative TSF++/React rules for all .tsx and React-relevant .ts files:
|
|
5
|
+
component shape, props contracts, state elimination ladder, effect discipline,
|
|
6
|
+
memoization policy, server state via TanStack Query, forms via React Hook Form
|
|
7
|
+
and Zod, routing via TanStack Router, global state via Zustand/Jotai, Tailwind
|
|
8
|
+
and cva styling, accessibility, testing, and module organisation. Profile of
|
|
9
|
+
coding-standard — all base TSF++ rules still apply. Load when writing or
|
|
10
|
+
reviewing any component, hook, form, route, or store file.
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# TSF++/React coding standard — v1.0.0
|
|
14
|
+
|
|
15
|
+
Profile of `CODING_STANDARD.md`. Every base TSF++ rule applies unchanged. This skill adds React-specific rules and refinements.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Never (MUST NOT — React-specific additions)
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
class components / function declarations for components (Rule 1.1)
|
|
23
|
+
module-level mutable state in components or hooks (Rule 1.5)
|
|
24
|
+
optional flag props for mutually exclusive variants (Rule 2.2)
|
|
25
|
+
prop drilling beyond two component levels (Rule 2.3)
|
|
26
|
+
{...rest} spread onto DOM without explicit allow-list (Rule 2.4)
|
|
27
|
+
DOM event types in domain callbacks (Rule 2.7)
|
|
28
|
+
useState for server state (Rule 3.2)
|
|
29
|
+
URL-shaped state in component state (Rule 3.3)
|
|
30
|
+
I/O inside reducers or store actions (Rule 3.8)
|
|
31
|
+
Context for high-frequency / per-keystroke state (Rule 3.7)
|
|
32
|
+
useEffect for derived state (Rule 4.2)
|
|
33
|
+
useEffect for data fetching (Rule 4.3)
|
|
34
|
+
useEffect for user-event reactions (Rule 4.4)
|
|
35
|
+
effect that subscribes without returning a cleanup (Rule 4.5)
|
|
36
|
+
disabling react-hooks/exhaustive-deps (Rule 4.6)
|
|
37
|
+
speculative useMemo / useCallback / React.memo (Rule 5.1–5.3)
|
|
38
|
+
inline fetch in components (Rule 7.1)
|
|
39
|
+
inline string-array query keys (Rule 7.2)
|
|
40
|
+
throw in submit handlers (Rule 8.2)
|
|
41
|
+
per-field useState in forms (Rule 8.5)
|
|
42
|
+
hand-built URL strings for navigation (Rule 9.3)
|
|
43
|
+
useStore((s) => s) — whole-store selection (Rule 10.4)
|
|
44
|
+
inline hex codes or magic pixel values in className (Rule 11.4)
|
|
45
|
+
if/else string concatenation for Tailwind variants (Rule 11.3)
|
|
46
|
+
div with onClick for an action (Rule 16.1)
|
|
47
|
+
data-testid queries in tests (Rule 17.1)
|
|
48
|
+
stubbing fetch in tests (Rule 17.3)
|
|
49
|
+
cross-feature deep imports past a feature's barrel (Rule 18.2)
|
|
50
|
+
shared/ui importing from features/* (Rule 18.3)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## 1 — Component shape
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
// MUST: arrow const, explicit return type, ReactElement | ReactNode | null
|
|
59
|
+
const UserCard = ({ user }: UserCardProps): ReactElement => (
|
|
60
|
+
<article className="rounded-lg border p-4">
|
|
61
|
+
<h2>{user.displayName}</h2>
|
|
62
|
+
</article>
|
|
63
|
+
)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
- One public (exported) component per file (Rule 1.3)
|
|
67
|
+
- `.tsx` if and only if the file contains JSX (Rule 1.4)
|
|
68
|
+
- Co-locate component, types, test, story in one directory (Rule 1.6)
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## 2 — Props contracts
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
// MUST: type alias, Props suffix, all fields readonly
|
|
76
|
+
type ButtonProps =
|
|
77
|
+
| { readonly kind: 'submit'; readonly loading: boolean; readonly children: ReactNode }
|
|
78
|
+
| { readonly kind: 'link'; readonly href: string; readonly children: ReactNode }
|
|
79
|
+
| { readonly kind: 'icon'; readonly icon: ReactElement; readonly label: string }
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
- Mutually exclusive variants → discriminated union, never optional flags (Rule 2.2)
|
|
83
|
+
- Callbacks accept domain types, not DOM events (Rule 2.7): `onSelect: (id: UserId) => void`
|
|
84
|
+
- Boolean props named affirmatively: `open`, `closable` — not `isOpen`, `enabled` (Rule 2.5)
|
|
85
|
+
- Content slots: `ReactNode` over `string | ReactElement` (Rule 2.6)
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## 3 — State elimination ladder (Rule 3.1)
|
|
90
|
+
|
|
91
|
+
Exhaust top-to-bottom before introducing state:
|
|
92
|
+
|
|
93
|
+
1. Derivable from props? → compute during render
|
|
94
|
+
2. Derivable from existing state? → compute during render or `useMemo` if expensive
|
|
95
|
+
3. Belongs in URL? → router (search params, path params)
|
|
96
|
+
4. Server data? → TanStack Query
|
|
97
|
+
5. Form state? → React Hook Form
|
|
98
|
+
6. Ephemeral UI state, one component? → `useState` / `useReducer`
|
|
99
|
+
7. Shared between siblings? → lift to common ancestor (Rule 3.5: lift no higher than needed)
|
|
100
|
+
8. Shared across distant subtrees, low-frequency? → Context
|
|
101
|
+
9. Shared across distant subtrees, high-frequency? → Zustand / Jotai
|
|
102
|
+
|
|
103
|
+
Skip a rung with `// DEVIATION(3.1): <reason>`.
|
|
104
|
+
|
|
105
|
+
**`useReducer` over `useState`** when state has more than two related fields or transitions form a state machine (Rule 3.4):
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
type WizardState =
|
|
109
|
+
| { readonly kind: 'step1' }
|
|
110
|
+
| { readonly kind: 'step2'; readonly name: string }
|
|
111
|
+
| { readonly kind: 'submitted'; readonly result: SubmitResult }
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## 4 — Effect discipline
|
|
117
|
+
|
|
118
|
+
`useEffect` is reserved exclusively for synchronizing with systems **outside React**: subscriptions, browser APIs, imperative libraries, observers (Rule 4.1).
|
|
119
|
+
|
|
120
|
+
Legitimate: WebSocket, `IntersectionObserver`, `document.title`, canvas/map imperative init.
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
// MUST: cleanup every subscribing effect
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
const id = window.setInterval(tick, 1000)
|
|
126
|
+
return () => window.clearInterval(id) // Rule 4.5
|
|
127
|
+
}, [tick])
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
| Pattern | Correct tool |
|
|
131
|
+
|---|---|
|
|
132
|
+
| Derived value | Compute inline or `useMemo` |
|
|
133
|
+
| Data fetching | TanStack Query |
|
|
134
|
+
| User-event reaction | Event handler |
|
|
135
|
+
| Subscription | `useEffect` with cleanup |
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## 5 — Memoization policy
|
|
140
|
+
|
|
141
|
+
**Speculative memoization is forbidden.** Add only after a profiler measurement identifies re-renders as the bottleneck.
|
|
142
|
+
|
|
143
|
+
- `useMemo` — only when result is passed to a memoized consumer or is genuinely expensive (Rule 5.1)
|
|
144
|
+
- `useCallback` — only when callback is passed to a memoized component or used in another hook's dep array (Rule 5.2)
|
|
145
|
+
- `React.memo` — only after profiler measurement (Rule 5.3)
|
|
146
|
+
- When the React Compiler is enabled: remove all manual memoization (Rule 5.5)
|
|
147
|
+
|
|
148
|
+
Always document the reason inline: `// Reason: passed to memoized DataGrid`
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## 6 — Composition patterns
|
|
153
|
+
|
|
154
|
+
- Prefer composition (children, slots) over deeply parameterized props (Rule 6.1)
|
|
155
|
+
- Compound components communicate via typed context, not implicit child ordering (Rule 6.2)
|
|
156
|
+
- Component does exactly one thing: present, fetch, lay out, or coordinate (Rule 6.4)
|
|
157
|
+
- Presentation components must be storybookable with mock props only (Rule 6.5)
|
|
158
|
+
- JSX nesting depth ≤ 4; extract sub-components beyond that (Rule 6.6)
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
## 7 — Server state (TanStack Query)
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
// Query key factory — typed, factory-shaped (Rule 7.2)
|
|
166
|
+
const userKeys = {
|
|
167
|
+
all: ['users'] as const,
|
|
168
|
+
byId: (id: UserId) => [...userKeys.all, id] as const,
|
|
169
|
+
list: (f: UserFilter) => [...userKeys.all, 'list', f] as const,
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Query functions return T; mutation functions return Result<T, E> (Rule 7.3)
|
|
173
|
+
// Post-mutation effects (invalidation, navigation, toasts) in onSuccess/onError (Rule 7.4)
|
|
174
|
+
// Optimistic updates require all three: onMutate + onError rollback + onSettled (Rule 7.5)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Use TanStack Router loaders for route-level data; component-level queries for sub-resources and on-demand reads (Rule 7.6).
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## 8 — Forms (React Hook Form + Zod)
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
// MUST: Zod schema is the single source of truth (Rule 8.1)
|
|
185
|
+
const userSchema = z.object({
|
|
186
|
+
email: z.string().email(),
|
|
187
|
+
age: z.number().int().min(18),
|
|
188
|
+
})
|
|
189
|
+
type UserForm = z.infer<typeof userSchema>
|
|
190
|
+
|
|
191
|
+
const form = useForm<UserForm>({ resolver: zodResolver(userSchema) })
|
|
192
|
+
|
|
193
|
+
// Submit returns Result<T, E> — never throw (Rule 8.2)
|
|
194
|
+
const onSubmit = async (data: UserForm): Promise<Result<User, FormError>> => { ... }
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
- Field validation in the schema only, never in `onChange` handlers (Rule 8.3)
|
|
198
|
+
- Compose schemas (`partial`, `pick`, `extend`) instead of duplicating them (Rule 8.4)
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
## 9 — Routing (TanStack Router)
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
// MUST: typed navigate; never hand-built URL strings (Rule 9.3)
|
|
206
|
+
navigate({ to: '/users/$id', params: { id } })
|
|
207
|
+
|
|
208
|
+
// MUST: search params validated by Zod at the route definition (Rule 9.2)
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
- Routes declared with full type inference for params and search (Rule 9.1)
|
|
212
|
+
- Lazy-load routes not on the critical path (Rule 9.4)
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
## 10 — Global state (Zustand / Jotai)
|
|
217
|
+
|
|
218
|
+
Global store exists only after the elimination ladder is exhausted (Rule 10.1).
|
|
219
|
+
|
|
220
|
+
```ts
|
|
221
|
+
// MUST: narrow selection — never whole-store (Rule 10.4)
|
|
222
|
+
const userName = useUserStore((s) => s.user.name)
|
|
223
|
+
|
|
224
|
+
// Store actions are pure (state, payload) => state; no I/O inside (Rule 10.3)
|
|
225
|
+
// Zustand: sliced by domain (Rule 10.2)
|
|
226
|
+
// Jotai: prefer for graph/canvas/grid with many small independent atoms (Rule 10.5)
|
|
227
|
+
// Persistence: opt-in per slice/atom; validate with Zod on rehydrate (Rule 10.6)
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
## 11 — Styling (Tailwind + shadcn/ui)
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
// MUST: cva for variants (Rule 11.3)
|
|
236
|
+
const buttonVariants = cva('rounded-lg px-3 py-2', {
|
|
237
|
+
variants: {
|
|
238
|
+
variant: {
|
|
239
|
+
primary: 'bg-primary text-primary-foreground',
|
|
240
|
+
destructive: 'bg-destructive text-destructive-foreground',
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
defaultVariants: { variant: 'primary' },
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
// MUST: cn/clsx for conditional classes (Rule 11.2)
|
|
247
|
+
const cls = cn('rounded-lg p-4', active && 'ring-2', disabled && 'opacity-50')
|
|
248
|
+
|
|
249
|
+
// MUST: design tokens only — no hex/pixel literals (Rule 11.4)
|
|
250
|
+
// Good: className="bg-background text-foreground gap-4"
|
|
251
|
+
// Bad: className="bg-[#0a0a0a] gap-[17px]"
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
- shadcn/ui components are vendored, not depended on as a package (Rule 11.5)
|
|
255
|
+
- `style={{ ... }}` only for runtime-computed values (e.g. CSS variables driven by state) (Rule 11.6)
|
|
256
|
+
|
|
257
|
+
---
|
|
258
|
+
|
|
259
|
+
## 12 — Animation (framer-motion)
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
// MUST: variants at module scope, not inline (Rule 12.2)
|
|
263
|
+
const fadeVariants = { hidden: { opacity: 0 }, visible: { opacity: 1 } } as const
|
|
264
|
+
|
|
265
|
+
// MUST: respect prefers-reduced-motion (Rule 12.3)
|
|
266
|
+
const reduced = useReducedMotion()
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
CSS keyframes only for purely decorative loops with no state interaction.
|
|
270
|
+
|
|
271
|
+
---
|
|
272
|
+
|
|
273
|
+
## 13 — Loading and error boundaries
|
|
274
|
+
|
|
275
|
+
- Every async dependency has explicit loading and error states (Rule 13.1)
|
|
276
|
+
- Error boundaries wrap every route and isolated feature subtree (Rule 13.2)
|
|
277
|
+
- Error boundaries log to an observability sink and show a recoverable fallback (Rule 13.3)
|
|
278
|
+
- Suspense boundaries at content-meaningful seams, not per-atom (Rule 13.4)
|
|
279
|
+
|
|
280
|
+
---
|
|
281
|
+
|
|
282
|
+
## 14 — Custom hooks
|
|
283
|
+
|
|
284
|
+
- Name starts with `use` (Rule 14.1)
|
|
285
|
+
- One hook, one responsibility; >1 unrelated returned values → split (Rule 14.2)
|
|
286
|
+
- Consistent return shape: value, tuple, or record — pick one (Rule 14.3)
|
|
287
|
+
- Return type is explicit and exported (Rule 14.4)
|
|
288
|
+
|
|
289
|
+
---
|
|
290
|
+
|
|
291
|
+
## 15 — Performance
|
|
292
|
+
|
|
293
|
+
- Lists with >~50 visible items use TanStack Virtual or equivalent (Rule 15.3)
|
|
294
|
+
- Heavy dependencies (charts, editors, PDF) are dynamic-imported (Rule 15.2)
|
|
295
|
+
- Profile before optimizing; DevTools Profiler is the source of truth (Rule 15.5)
|
|
296
|
+
|
|
297
|
+
---
|
|
298
|
+
|
|
299
|
+
## 16 — Accessibility
|
|
300
|
+
|
|
301
|
+
- Interactive elements use semantic HTML: `button`, `a`, `input`, `select` — never `div` with `onClick` (Rule 16.1)
|
|
302
|
+
- Forms: associated `label`; icon-buttons: `aria-label`; images: `alt` (Rule 16.2)
|
|
303
|
+
- Keyboard navigation covers every interactive flow; focus order logical, visible, traps escapable (Rule 16.3)
|
|
304
|
+
- Color is never the sole channel for state; pair with text or icon (Rule 16.4)
|
|
305
|
+
- Radix UI / shadcn/ui for dialogs, menus, popovers, tabs, tooltips (Rule 16.5)
|
|
306
|
+
|
|
307
|
+
---
|
|
308
|
+
|
|
309
|
+
## 17 — Testing
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
// MUST: query by accessible role, not data-testid (Rule 17.1)
|
|
313
|
+
screen.getByRole('button', { name: /submit/i })
|
|
314
|
+
|
|
315
|
+
// MUST: network mocked with MSW, not by stubbing fetch (Rule 17.3)
|
|
316
|
+
// Pure logic (reducers, selectors, validators) unit-tested + fast-check (Rule 17.2)
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
---
|
|
320
|
+
|
|
321
|
+
## 18 — Module organisation
|
|
322
|
+
|
|
323
|
+
```
|
|
324
|
+
src/
|
|
325
|
+
features/
|
|
326
|
+
user-profile/
|
|
327
|
+
UserCard.tsx ← component
|
|
328
|
+
UserCard.types.ts
|
|
329
|
+
UserCard.test.tsx
|
|
330
|
+
useUser.ts ← hook
|
|
331
|
+
userKeys.ts ← query key factory
|
|
332
|
+
userSchema.ts ← Zod schema
|
|
333
|
+
shared/
|
|
334
|
+
ui/ ← shadcn primitives, generic atoms (no features/* imports)
|
|
335
|
+
hooks/ ← cross-feature hooks
|
|
336
|
+
lib/ ← cn, formatters, type guards
|
|
337
|
+
routes/ ← route tree
|
|
338
|
+
app/ ← root, providers, error boundary
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
- Cross-feature imports go through the feature's `index.ts` barrel only (Rule 18.2)
|
|
342
|
+
- Path aliases (`@/features/...`) configured in `tsconfig.json`; no deep relative imports (Rule 18.4)
|
package/init.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* @tsfpp/agents init
|
|
4
4
|
*
|
|
5
|
-
* Copies Copilot agents, instructions, prompts, and Claude Code configuration
|
|
5
|
+
* Copies Copilot agents, instructions, prompts, skills, and Claude Code configuration
|
|
6
6
|
* into the correct locations in the consumer's project. Also generates
|
|
7
7
|
* eslint.config.js if not already present.
|
|
8
8
|
*
|
|
@@ -48,6 +48,12 @@ const FILES = [
|
|
|
48
48
|
['copilot/prompts/tsfpp-new-module.prompt.md', '.github/prompts/tsfpp-new-module.prompt.md'],
|
|
49
49
|
['copilot/prompts/tsfpp-boundary-review.prompt.md', '.github/prompts/tsfpp-boundary-review.prompt.md'],
|
|
50
50
|
|
|
51
|
+
// Reusable skills
|
|
52
|
+
['copilot/skills/coding-standard/SKILL.md', '.github/skills/coding-standard/SKILL.md'],
|
|
53
|
+
['copilot/skills/prelude-api/SKILL.md', '.github/skills/prelude-api/SKILL.md'],
|
|
54
|
+
['copilot/skills/boundary-api/SKILL.md', '.github/skills/boundary-api/SKILL.md'],
|
|
55
|
+
['copilot/skills/react-coding-standard/SKILL.md', '.github/skills/react-coding-standard/SKILL.md'],
|
|
56
|
+
|
|
51
57
|
// Claude Code
|
|
52
58
|
['claude/CLAUDE.md', '.claude/CLAUDE.md'],
|
|
53
59
|
];
|
|
@@ -186,7 +192,7 @@ async function confirm(question) {
|
|
|
186
192
|
|
|
187
193
|
console.log();
|
|
188
194
|
console.log(bold(' @tsfpp/agents — init'));
|
|
189
|
-
console.log(dim(' Sets up Copilot agents, instructions, and ESLint config.\n'));
|
|
195
|
+
console.log(dim(' Sets up Copilot agents, instructions, prompts, skills, and ESLint config.\n'));
|
|
190
196
|
|
|
191
197
|
const results = { copied: [], skipped: [], failed: [] };
|
|
192
198
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tsfpp/agents",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "Workspace AI tooling for TSF++ projects: scoped instructions, coding agents, and reusable prompts",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"tsfpp",
|
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
"node": ">=18.0.0"
|
|
26
26
|
},
|
|
27
27
|
"bin": {
|
|
28
|
-
"tsfpp-agents": "./init.mjs"
|
|
28
|
+
"tsfpp-agents": "./init.mjs",
|
|
29
|
+
"tsfpp-bootstrap": "./bin/bootstrap.sh"
|
|
29
30
|
},
|
|
30
31
|
"files": [
|
|
31
32
|
"copilot/",
|