@tsfpp/agents 1.3.5 → 1.5.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.
- package/CHANGELOG.md +30 -0
- package/README.md +218 -31
- package/bin/bootstrap.sh +0 -0
- package/copilot/agents/tsfpp-annotate.agent.md +99 -119
- package/copilot/agents/tsfpp-audit.agent.md +123 -32
- package/copilot/agents/tsfpp-guarded-coding.agent.md +25 -0
- package/copilot/instructions/tsfpp-base.instructions.md +5 -0
- package/copilot/prompts/trunk-changelog.prompt.md +160 -0
- package/copilot/skills/annotation-standard/SKILL.md +196 -0
- package/copilot/skills/config-standard/SKILL.md +205 -0
- package/copilot/skills/log-standard/SKILL.md +148 -0
- package/init.mjs +4 -0
- package/package.json +6 -6
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# Write changelog entry
|
|
2
|
+
|
|
3
|
+
Inspect the current working tree, derive the correct conventional commit message,
|
|
4
|
+
and append a matching entry to the `## [Unreleased]` section of `CHANGELOG.md`.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Step 1 — Inspect changes
|
|
9
|
+
|
|
10
|
+
Run the following to see what has changed:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
git diff --stat HEAD
|
|
14
|
+
git status --short
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
For each changed or added file, read enough of its diff to understand **what
|
|
18
|
+
changed and why** — not just which lines moved.
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
git diff HEAD -- <file>
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
If files are staged but not committed, use:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
git diff --cached --stat
|
|
28
|
+
git diff --cached -- <file>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Step 2 — Classify changes
|
|
34
|
+
|
|
35
|
+
Map each change to a Conventional Commits type:
|
|
36
|
+
|
|
37
|
+
| Type | Use when |
|
|
38
|
+
|---|---|
|
|
39
|
+
| `feat` | New behaviour or capability visible to a consumer |
|
|
40
|
+
| `fix` | Corrects incorrect behaviour |
|
|
41
|
+
| `perf` | Improves performance without changing behaviour |
|
|
42
|
+
| `refactor` | Internal restructuring; no behaviour change, no bug fix |
|
|
43
|
+
| `test` | Adds or fixes tests; no production code change |
|
|
44
|
+
| `docs` | Documentation only |
|
|
45
|
+
| `chore` | Tooling, config, dependencies, release machinery |
|
|
46
|
+
| `build` | Build system or external dependency changes |
|
|
47
|
+
| `ci` | CI configuration changes |
|
|
48
|
+
|
|
49
|
+
**Breaking change:** any change that removes or renames a public export, changes
|
|
50
|
+
a function signature, or alters a type in a way that requires consumer updates.
|
|
51
|
+
Mark with `!` after the type (e.g. `feat!`) and add a `BREAKING CHANGE:` footer.
|
|
52
|
+
|
|
53
|
+
**Scope:** the package or module affected — e.g. `prelude`, `boundary`, `agents`,
|
|
54
|
+
`react`, `dal`. Omit if the change is cross-cutting.
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## Step 3 — Write the commit message
|
|
59
|
+
|
|
60
|
+
Produce a conventional commit message following this format exactly:
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
<type>(<scope>): <imperative summary in sentence case, ≤ 72 chars>
|
|
64
|
+
|
|
65
|
+
<optional body — what changed and why, not how, wrapped at 72 chars>
|
|
66
|
+
|
|
67
|
+
<optional footers>
|
|
68
|
+
BREAKING CHANGE: <description if applicable>
|
|
69
|
+
Closes #<issue> (if applicable)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Rules:
|
|
73
|
+
- Summary is imperative mood: "add", "fix", "remove" — not "added", "fixes"
|
|
74
|
+
- Summary does not end with a period
|
|
75
|
+
- Body explains the **why**, not the **what** (the diff is the what)
|
|
76
|
+
- One commit per logical change; if the diff contains multiple unrelated changes,
|
|
77
|
+
produce one message per change and say so
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## Step 4 — Update CHANGELOG.md
|
|
82
|
+
|
|
83
|
+
Find or create the `## [Unreleased]` section at the top of `CHANGELOG.md`.
|
|
84
|
+
If `CHANGELOG.md` does not exist, create it with this header:
|
|
85
|
+
|
|
86
|
+
```markdown
|
|
87
|
+
# Changelog
|
|
88
|
+
|
|
89
|
+
All notable changes to this project will be documented in this file.
|
|
90
|
+
This file is maintained automatically by [release-please](https://github.com/googleapis/release-please)
|
|
91
|
+
and supplemented during development via the `/trunk-changelog` prompt.
|
|
92
|
+
|
|
93
|
+
<!-- do not remove this comment — release-please uses it as an anchor -->
|
|
94
|
+
<!-- RELEASE-PLEASE-INSERTION-POINT -->
|
|
95
|
+
|
|
96
|
+
## [Unreleased]
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Append the new entry under `## [Unreleased]`, grouped by type in this order:
|
|
100
|
+
|
|
101
|
+
```markdown
|
|
102
|
+
## [Unreleased]
|
|
103
|
+
|
|
104
|
+
### Breaking changes
|
|
105
|
+
- `feat!(boundary)!: remove legacy `fold` export` — consumers must migrate to `map`/`flatMap`
|
|
106
|
+
|
|
107
|
+
### Features
|
|
108
|
+
- `feat(prelude): add ReadonlyMap combinators` — `intoMap`, `assoc`, `dissoc`, `lookup`, `entriesOfMap`
|
|
109
|
+
|
|
110
|
+
### Bug fixes
|
|
111
|
+
- `fix(agents): init.mjs fails with ReferenceError when run with --yes`
|
|
112
|
+
|
|
113
|
+
### Performance
|
|
114
|
+
- ...
|
|
115
|
+
|
|
116
|
+
### Refactoring
|
|
117
|
+
- ...
|
|
118
|
+
|
|
119
|
+
### Tests
|
|
120
|
+
- ...
|
|
121
|
+
|
|
122
|
+
### Documentation
|
|
123
|
+
- ...
|
|
124
|
+
|
|
125
|
+
### Chores
|
|
126
|
+
- ...
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Only include sections that have entries. Do not add empty sections.
|
|
130
|
+
|
|
131
|
+
Each entry is a single line:
|
|
132
|
+
- Backtick-quoted commit summary
|
|
133
|
+
- Em dash
|
|
134
|
+
- One-sentence plain-English explanation of the user-visible impact
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## Step 5 — Output
|
|
139
|
+
|
|
140
|
+
Print the proposed commit message in a code block so it can be copied directly
|
|
141
|
+
into the terminal or used with `git commit`:
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
git commit -m "<type>(<scope>): <summary>" \
|
|
145
|
+
-m "<body paragraph if needed>"
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
If there are multiple logical changes, list each message separately and recommend
|
|
149
|
+
committing them individually with `git add -p` to stage per-change.
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## Rules
|
|
154
|
+
|
|
155
|
+
- Never invent changes that are not visible in the diff
|
|
156
|
+
- Never write a changelog entry for a change that has no user-visible impact
|
|
157
|
+
(internal renaming, comment edits) — use `chore` or `refactor` in the commit
|
|
158
|
+
message but omit from `## [Unreleased]`
|
|
159
|
+
- Do not modify any section of `CHANGELOG.md` other than `## [Unreleased]`
|
|
160
|
+
- release-please owns every versioned section (`## [1.2.3]`) — never touch those
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: annotation-standard
|
|
3
|
+
description: >
|
|
4
|
+
Normative TSF++ annotation rules for all comments, JSDoc blocks, module
|
|
5
|
+
headers, code markers, and deviation records. Load when writing, reviewing,
|
|
6
|
+
or adding annotations to any TypeScript file: the "why not what" principle,
|
|
7
|
+
JSDoc body content (invariants, rejected alternatives, external contracts,
|
|
8
|
+
accepted imprecision, performance trade-offs), marker taxonomy with format,
|
|
9
|
+
DEVIATION pairing, and what must never be annotated.
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# TSF++ annotation standard
|
|
13
|
+
|
|
14
|
+
Full standard: `node_modules/@tsfpp/standard/spec/ANNOTATION_CODING_STANDARD.md`
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## The single deciding question
|
|
19
|
+
|
|
20
|
+
> Does this tell the reader something they cannot confidently derive from the code and its types?
|
|
21
|
+
|
|
22
|
+
If no — do not add the comment. If yes — write it.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## What only a comment can tell you
|
|
27
|
+
|
|
28
|
+
| Category | Example |
|
|
29
|
+
|---|---|
|
|
30
|
+
| **Why this approach** over the natural alternative | Why linear scan instead of `Map` lookup |
|
|
31
|
+
| **Rejected alternatives** | What was considered and why it was ruled out |
|
|
32
|
+
| **Non-obvious invariants** | Preconditions the type cannot express |
|
|
33
|
+
| **Domain knowledge** | Business rules that live in the problem space |
|
|
34
|
+
| **External contracts** | Field names / values dictated by a third party |
|
|
35
|
+
| **Accepted imprecision** | Known limitations that are intentional |
|
|
36
|
+
| **Performance trade-offs** | Why a non-obvious implementation was chosen |
|
|
37
|
+
| **Temporal context** | Why a workaround exists, when to revisit it |
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## JSDoc body: the why, not the what
|
|
42
|
+
|
|
43
|
+
The first sentence is the purpose. Everything after is the reasoning.
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
/**
|
|
47
|
+
* Constructs a validated `UserId` from a raw string.
|
|
48
|
+
*
|
|
49
|
+
* Returns `None` if the input is empty. The empty case is excluded rather
|
|
50
|
+
* than mapped to an error because an empty ID indicates a caller bug, not
|
|
51
|
+
* a domain error — the type system prevents this at compile time in
|
|
52
|
+
* internal code; this guard exists for boundary inputs only.
|
|
53
|
+
*
|
|
54
|
+
* @param raw - The raw string to validate. Must be non-empty.
|
|
55
|
+
* @returns `Some(UserId)` if valid; `None` if empty.
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* mkUserId('usr-00123') // => some(UserId('usr-00123'))
|
|
59
|
+
* mkUserId('') // => none
|
|
60
|
+
*/
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`@param` describes the domain constraint, not the type. `@returns` describes the meaning, not the type. Both are already in the signature.
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Module header
|
|
68
|
+
|
|
69
|
+
Required on every file with public exports:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
/**
|
|
73
|
+
* @module user-account
|
|
74
|
+
*
|
|
75
|
+
* Domain model for user accounts. Provides the `UserAccount` sum type,
|
|
76
|
+
* its smart constructors, and the combinators for working with account
|
|
77
|
+
* state and identity.
|
|
78
|
+
*
|
|
79
|
+
* All functions are pure and total. Error cases are modelled via
|
|
80
|
+
* `Option<A>` (absent value) or `Result<T, E>` (fallible computation).
|
|
81
|
+
*
|
|
82
|
+
* @packageDocumentation
|
|
83
|
+
*/
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
First sentence: what the module provides. Second paragraph: key design constraints a consumer needs to know. Never describe the implementation.
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## Inline comment patterns
|
|
91
|
+
|
|
92
|
+
### Rejected alternative
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
// NOTE(rob, 2026-05-18): Linear scan rather than `ReadonlyMap` lookup.
|
|
96
|
+
// The active session list is always ≤10 items per user; the allocation
|
|
97
|
+
// overhead of a map outweighs the O(1) lookup benefit at this scale.
|
|
98
|
+
const found = sessions.find(s => s.id === id)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Non-obvious invariant
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
// Invariant: `handlers` must be registered before this is called.
|
|
105
|
+
// The runtime guarantees registration order; do not call from a module
|
|
106
|
+
// initialiser that may run before the framework bootstraps.
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### External contract
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
// IMPORTANT: The field name `client_id` is specified by OAuth2 RFC 6749
|
|
113
|
+
// and must not be renamed despite the camelCase convention.
|
|
114
|
+
// DEVIATION(1.8): Field name required by external protocol.
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Accepted imprecision
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
// NOTE(rob, 2026-05-18): Timestamp comparison has ≤1 s imprecision due
|
|
121
|
+
// to clock drift between service instances. Acceptable for audit logs;
|
|
122
|
+
// not acceptable for financial ordering.
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## Code markers
|
|
128
|
+
|
|
129
|
+
Required format — no exceptions:
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
// MARKER(author, YYYY-MM-DD[, TICKET]): description
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
| Marker | Use when | Blocks merge? |
|
|
136
|
+
|---|---|---|
|
|
137
|
+
| `TODO` | Work required before next release | Soft — needs ticket |
|
|
138
|
+
| `FIXME` | Known bug the author is aware of | Yes |
|
|
139
|
+
| `HACK` | Temporary workaround with a deferred correct solution | Yes — needs ticket + revisit condition |
|
|
140
|
+
| `NOTE` | Context a reader needs to understand the code | No |
|
|
141
|
+
| `OPTIMIZE` | Correct but with a known performance concern at scale | No — needs scale threshold |
|
|
142
|
+
| `BUG` | Confirmed bug not yet in a ticket | Yes — convert to FIXME + ticket |
|
|
143
|
+
| `XXX` | Fragile or load-bearing — must not be casually changed | No — use sparingly |
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
// TODO(rjansen, 2026-05-18, ARCH-44): Replace with Result-based validation
|
|
147
|
+
// once the boundary refactor lands in v2.0.
|
|
148
|
+
// HACK(rjansen, 2026-05-18, INFRA-12): Forced cast — third-party type
|
|
149
|
+
// definition is wrong. Fixed upstream in v4.x — remove after upgrade.
|
|
150
|
+
// NOTE(rjansen, 2026-05-18): Rate-limit window resets at midnight UTC,
|
|
151
|
+
// not relative to first request. Contractual requirement — do not change.
|
|
152
|
+
// XXX(rjansen, 2026-05-18): Initialisation order is load-bearing. The
|
|
153
|
+
// store must be hydrated before any handler is registered.
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Author = GitHub handle or initials. Never an AI. If unknown, use `unknown`.
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## DEVIATION format
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
// DEVIATION(N.M): <reason the violation could not be avoided>
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
The justification explains why no alternative was feasible — not what the violation is.
|
|
167
|
+
|
|
168
|
+
Every `eslint-disable` must be paired:
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
// DEVIATION(1.5): Legacy adapter — raw type narrowed to unknown immediately below.
|
|
172
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
173
|
+
const payload: any = deserialise(raw)
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
The `as` in a smart constructor body:
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- DEVIATION(1.6): smart-constructor body
|
|
180
|
+
return some(raw as UserId)
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## Never annotate
|
|
186
|
+
|
|
187
|
+
| Forbidden | Example |
|
|
188
|
+
|---|---|
|
|
189
|
+
| Paraphrasing the code | `// Check if user is admin` above `if (user.role === 'admin')` |
|
|
190
|
+
| Restating the type | `// Returns a string` on `: string` |
|
|
191
|
+
| Commented-out code | `// const old = legacyParse(raw)` |
|
|
192
|
+
| Section dividers | `// ─────────────` with nothing meaningful |
|
|
193
|
+
| Stale comments | Any comment that no longer matches the code |
|
|
194
|
+
| AI attribution | `// Generated by Claude` |
|
|
195
|
+
| `@throws` on Result functions | Error is in the return type, not thrown |
|
|
196
|
+
| Apologetic comments | `// This is a bit hacky but...` — use `HACK` properly |
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: config-standard
|
|
3
|
+
description: >
|
|
4
|
+
Normative TSF++ configuration management rules. Load when writing or reviewing
|
|
5
|
+
any code that loads environment variables, defines a Config type, calls
|
|
6
|
+
loadConfig, accesses process.env, implements a config loader, or writes
|
|
7
|
+
.env.example: loadConfig from @tsfpp/boundary, Config as typed readonly record,
|
|
8
|
+
Zod validation at the startup boundary, injection pattern, test factories.
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# TSF++ config standard
|
|
12
|
+
|
|
13
|
+
Full standard: `node_modules/@tsfpp/standard/spec/CONFIG_CODING_STANDARD.md`
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## The pattern in one picture
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
process.env (string | undefined)
|
|
21
|
+
↓
|
|
22
|
+
loadConfig(schema, env) ← @tsfpp/boundary — validates all vars at once
|
|
23
|
+
↓
|
|
24
|
+
Result<Config, ConfigError>
|
|
25
|
+
↓ exit on Err at startup
|
|
26
|
+
Config (typed readonly record)
|
|
27
|
+
↓ injected into every module that needs it
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`process.env` is only touched at the entry point. Everything downstream receives a typed `Config`.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Imports
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
// In the config loader
|
|
38
|
+
import { loadConfig, type ConfigError } from '@tsfpp/boundary'
|
|
39
|
+
import { isErr, ok, type Result } from '@tsfpp/prelude'
|
|
40
|
+
|
|
41
|
+
// In application modules — inject Config as a dependency, never import process.env
|
|
42
|
+
import { type Config } from '../shared/config'
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Config type — project-defined, not from a package
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
// src/shared/config.ts
|
|
51
|
+
export type Config = {
|
|
52
|
+
readonly server: {
|
|
53
|
+
readonly port: number
|
|
54
|
+
readonly host: string
|
|
55
|
+
readonly logLevel: 'debug' | 'info' | 'warn' | 'error'
|
|
56
|
+
}
|
|
57
|
+
readonly database: {
|
|
58
|
+
readonly url: string
|
|
59
|
+
readonly poolMin: number
|
|
60
|
+
readonly poolMax: number
|
|
61
|
+
readonly queryTimeoutMs: number
|
|
62
|
+
}
|
|
63
|
+
readonly auth: {
|
|
64
|
+
readonly jwtSecret: string
|
|
65
|
+
readonly tokenTtlSeconds: number
|
|
66
|
+
}
|
|
67
|
+
readonly features: {
|
|
68
|
+
readonly maintenanceMode: boolean
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
All fields are required. Optional config uses `Option<T>`, not `T | undefined`.
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Config loader
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
// src/infrastructure/config-loader.ts
|
|
81
|
+
import { z } from 'zod'
|
|
82
|
+
import { loadConfig, type ConfigError } from '@tsfpp/boundary'
|
|
83
|
+
import { isErr, ok, type Result } from '@tsfpp/prelude'
|
|
84
|
+
import { type Config } from '../shared/config'
|
|
85
|
+
|
|
86
|
+
const schema = z.object({
|
|
87
|
+
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
|
|
88
|
+
HOST: z.string().default('0.0.0.0'),
|
|
89
|
+
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
|
|
90
|
+
DATABASE_URL: z.string().url(),
|
|
91
|
+
DATABASE_POOL_MIN: z.coerce.number().int().min(1).default(2),
|
|
92
|
+
DATABASE_POOL_MAX: z.coerce.number().int().min(1).default(10),
|
|
93
|
+
DATABASE_TIMEOUT_MS: z.coerce.number().int().min(100).default(5000),
|
|
94
|
+
JWT_SECRET: z.string().min(32),
|
|
95
|
+
TOKEN_TTL_SECONDS: z.coerce.number().int().min(60).default(3600),
|
|
96
|
+
MAINTENANCE_MODE: z.coerce.boolean().default(false),
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
export const parseConfig = (
|
|
100
|
+
env: Record<string, string | undefined>
|
|
101
|
+
): Result<Config, ConfigError> => {
|
|
102
|
+
const raw = loadConfig(schema, env)
|
|
103
|
+
if (isErr(raw)) return raw
|
|
104
|
+
|
|
105
|
+
const e = raw.value
|
|
106
|
+
return ok({
|
|
107
|
+
server: { port: e.PORT, host: e.HOST, logLevel: e.LOG_LEVEL },
|
|
108
|
+
database: { url: e.DATABASE_URL, poolMin: e.DATABASE_POOL_MIN,
|
|
109
|
+
poolMax: e.DATABASE_POOL_MAX, queryTimeoutMs: e.DATABASE_TIMEOUT_MS },
|
|
110
|
+
auth: { jwtSecret: e.JWT_SECRET, tokenTtlSeconds: e.TOKEN_TTL_SECONDS },
|
|
111
|
+
features: { maintenanceMode: e.MAINTENANCE_MODE },
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## Entry point — fail at startup
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
// src/main.ts
|
|
122
|
+
import { parseConfig } from './infrastructure/config-loader'
|
|
123
|
+
|
|
124
|
+
const configResult = parseConfig(process.env)
|
|
125
|
+
if (isErr(configResult)) {
|
|
126
|
+
console.error(configResult.error.summary)
|
|
127
|
+
process.exit(1)
|
|
128
|
+
}
|
|
129
|
+
const config = configResult.value
|
|
130
|
+
// wire up the application with config
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## Injection — never import process.env in modules
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
// Good — Config injected as part of Deps
|
|
139
|
+
type Deps = {
|
|
140
|
+
readonly db: Database
|
|
141
|
+
readonly logger: Logger
|
|
142
|
+
readonly config: Pick<Config, 'auth'>
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Bad — reads process.env inside a module
|
|
146
|
+
const ttl = parseInt(process.env.TOKEN_TTL_SECONDS ?? '3600', 10)
|
|
147
|
+
|
|
148
|
+
// Bad — imports a config singleton
|
|
149
|
+
import { config } from '../config'
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Use `Pick<Config, 'auth'>` to declare precisely which slice of config a module needs.
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## Zod schema conventions
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
// Required — no default; missing = startup failure
|
|
160
|
+
DATABASE_URL: z.string().url()
|
|
161
|
+
JWT_SECRET: z.string().min(32) // enforce minimum entropy for secrets
|
|
162
|
+
|
|
163
|
+
// Optional with sensible default
|
|
164
|
+
PORT: z.coerce.number().int().default(3000)
|
|
165
|
+
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info')
|
|
166
|
+
|
|
167
|
+
// Boolean — coerce from 'true' / 'false' string
|
|
168
|
+
MAINTENANCE_MODE: z.coerce.boolean().default(false)
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
All coercion (`string → number`, `string → boolean`) happens in the schema. Never parse in application code.
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## Testing
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
// Never mutate process.env in tests
|
|
179
|
+
// Bad
|
|
180
|
+
process.env.DATABASE_URL = 'postgres://localhost/test'
|
|
181
|
+
|
|
182
|
+
// Good — pass a plain record to the loader
|
|
183
|
+
const result = parseConfig({ DATABASE_URL: 'postgres://localhost/test', JWT_SECRET: 'a'.repeat(32), ... })
|
|
184
|
+
|
|
185
|
+
// Config factory for use-case and integration tests
|
|
186
|
+
// tests/helpers/config.factory.ts
|
|
187
|
+
export const makeConfig = (overrides: Partial<Config> = {}): Config => ({
|
|
188
|
+
server: { port: 3000, host: '127.0.0.1', logLevel: 'error', ...overrides.server },
|
|
189
|
+
database: { url: 'postgres://localhost/test', poolMin: 1, poolMax: 2,
|
|
190
|
+
queryTimeoutMs: 1000, ...overrides.database },
|
|
191
|
+
auth: { jwtSecret: 'a'.repeat(32), tokenTtlSeconds: 3600, ...overrides.auth },
|
|
192
|
+
features: { maintenanceMode: false, ...overrides.features },
|
|
193
|
+
...overrides,
|
|
194
|
+
})
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
## Never
|
|
200
|
+
|
|
201
|
+
- Access `process.env` outside the config loader
|
|
202
|
+
- Import a config singleton in application modules
|
|
203
|
+
- Coerce types (parseInt, parseFloat) inside application code
|
|
204
|
+
- Log config values or `process.env` at any level
|
|
205
|
+
- Commit `.env` — only `.env.example` is committed
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: log-standard
|
|
3
|
+
description: >
|
|
4
|
+
Normative TSF++ logging rules. Load when writing or reviewing any code that
|
|
5
|
+
logs, uses the Logger port, implements a logger adapter, or calls tap/tapErr
|
|
6
|
+
for side effects: Logger port from @tsfpp/prelude, LogEntry field conventions,
|
|
7
|
+
log level semantics, structured message format, what never to log (PII,
|
|
8
|
+
secrets, stack traces), withRequestLog for HTTP, silentLogger for tests.
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# TSF++ log standard
|
|
12
|
+
|
|
13
|
+
Full standard: `node_modules/@tsfpp/standard/spec/LOG_CODING_STANDARD.md`
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Import
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { type Logger, type LogEntry, type LogLevel } from '@tsfpp/prelude'
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Never import `pino`, `winston`, or `console` in core, use-case, or DAL code.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Logger port
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
// Infrastructure adapter — inject this; never the library directly
|
|
31
|
+
import pino from 'pino'
|
|
32
|
+
import { type Logger } from '@tsfpp/prelude'
|
|
33
|
+
|
|
34
|
+
export const logger: Logger = {
|
|
35
|
+
debug: (entry) => pinoInstance.debug(entry, entry.message),
|
|
36
|
+
info: (entry) => pinoInstance.info(entry, entry.message),
|
|
37
|
+
warn: (entry) => pinoInstance.warn(entry, entry.message),
|
|
38
|
+
error: (entry) => pinoInstance.error(entry, entry.message),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Tests — always use silentLogger, never the production logger
|
|
42
|
+
export const silentLogger: Logger = {
|
|
43
|
+
debug: () => undefined,
|
|
44
|
+
info: () => undefined,
|
|
45
|
+
warn: () => undefined,
|
|
46
|
+
error: () => undefined,
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Log levels
|
|
53
|
+
|
|
54
|
+
| Level | Use when |
|
|
55
|
+
|---|---|
|
|
56
|
+
| `debug` | Diagnostic detail — disabled in production by default. Never log PII even at debug. |
|
|
57
|
+
| `info` | A significant, expected business event occurred. One entry per meaningful outcome. |
|
|
58
|
+
| `warn` | Unexpected but recoverable — retry triggered, rate limit approached, deprecated path called. |
|
|
59
|
+
| `error` | Failure requiring attention — operation failed, dependency unreachable, unhandled `Err` at boundary. |
|
|
60
|
+
|
|
61
|
+
`info` is for **business events**, not execution steps. Never log "calling repository", "repository returned".
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## LogEntry field conventions
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
// message — dot-separated event name, machine-readable
|
|
69
|
+
{ message: 'user.created' }
|
|
70
|
+
{ message: 'payment.charge.failed' }
|
|
71
|
+
{ message: 'session.expired' }
|
|
72
|
+
|
|
73
|
+
// Always include traceId in request-scoped logs
|
|
74
|
+
{ message: 'user.created', traceId: ctx.traceId, userId: user.id }
|
|
75
|
+
|
|
76
|
+
// Always include code on error-level logs
|
|
77
|
+
{ message: 'db.query.failed', code: 'db_timeout', traceId }
|
|
78
|
+
|
|
79
|
+
// duration for operations with performance budgets (milliseconds)
|
|
80
|
+
{ message: 'payment.charge.completed', duration: Date.now() - start, traceId }
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Flat structure only — no nested objects. Log aggregators cannot query nested fields.
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Logging in pipelines — tap / tapErr
|
|
88
|
+
|
|
89
|
+
Never break a `pipe` chain to log. Use `tap` / `tapErr`:
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
// Good
|
|
93
|
+
pipe(
|
|
94
|
+
validateInput(input),
|
|
95
|
+
flatMap(createUser(deps.users)),
|
|
96
|
+
tap(user => deps.logger.info({ message: 'user.created', userId: user.id, traceId })),
|
|
97
|
+
tapErr(err => deps.logger.error({ message: 'user.create.failed', code: err.code, traceId })),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
// Bad — breaks the pipeline
|
|
101
|
+
const result = await createUser(deps.users)(input)
|
|
102
|
+
if (isOk(result)) deps.logger.info(...) // separate from the pipeline
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## Log `cause` before discarding it
|
|
108
|
+
|
|
109
|
+
`dependency` and `internal` `ApiError` variants carry a `cause` that is stripped by `apiErrorToResponse`. Log it first:
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
if (isErr(result) && result.error.kind === 'dependency') {
|
|
113
|
+
deps.logger.error({
|
|
114
|
+
message: 'payment.gateway.unreachable',
|
|
115
|
+
code: 'dependency_error',
|
|
116
|
+
error: String(result.error.cause),
|
|
117
|
+
traceId: ctx.traceId,
|
|
118
|
+
})
|
|
119
|
+
return apiErrorToResponse(result.error, ctx)
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## HTTP request logging
|
|
126
|
+
|
|
127
|
+
Use `withRequestLog` from `@tsfpp/boundary` — never add manual logging to handler bodies.
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
const handler = pipe(
|
|
131
|
+
createUserHandler(deps),
|
|
132
|
+
withIdempotency(store),
|
|
133
|
+
withRequestLog(logger, '/v1/users'), // always outermost; routeTemplate not resolved URL
|
|
134
|
+
)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`routeTemplate` must be the parameterised path (`/v1/users/:id`), never the resolved URL.
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## Never log
|
|
142
|
+
|
|
143
|
+
- PII — names, emails, phone numbers, addresses, national IDs
|
|
144
|
+
- Credentials — passwords, tokens, API keys, session IDs
|
|
145
|
+
- Full request or response bodies at `info` or above
|
|
146
|
+
- Stack traces in production — log `err.message`, not `err.stack`
|
|
147
|
+
- `process.env` or config values — they may contain secrets
|
|
148
|
+
- `console.*` anywhere except `main.ts` / `server.ts` startup boundary
|