@torus-engineering/tas-kit 1.8.0 → 1.10.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/.claude/commands/tas-apitest-plan.md +173 -0
- package/.claude/commands/tas-apitest.md +143 -0
- package/.claude/commands/tas-security.md +7 -1
- package/.tas/README.md +334 -1588
- package/.tas/hooks/README.md +138 -0
- package/.tas/hooks/pre-commit +26 -0
- package/.tas/hooks/security-scan.js +599 -0
- package/.tas/tas-example.yaml +126 -109
- package/.tas/templates/API-Test-Spec.md +400 -0
- package/CLAUDE-Example.md +61 -58
- package/README.md +334 -82
- package/bin/cli.js +24 -7
- package/lib/deleted-files.json +36 -33
- package/lib/install.js +161 -47
- package/package.json +1 -1
- package/.claude/commands/tas-api-test.md +0 -95
package/lib/install.js
CHANGED
|
@@ -31,16 +31,21 @@ async function removeDeletedFiles(target, deletedFiles) {
|
|
|
31
31
|
return { removed, skipped };
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
async function
|
|
34
|
+
async function ask(question, defaultValue = '') {
|
|
35
35
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
36
36
|
return new Promise((resolve) => {
|
|
37
|
-
rl.question(
|
|
37
|
+
rl.question(question, (answer) => {
|
|
38
38
|
rl.close();
|
|
39
|
-
resolve(answer
|
|
39
|
+
resolve((answer || '').trim() || defaultValue);
|
|
40
40
|
});
|
|
41
41
|
});
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
async function confirm(question) {
|
|
45
|
+
const answer = await ask(`${question} [y/N] `);
|
|
46
|
+
return answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes';
|
|
47
|
+
}
|
|
48
|
+
|
|
44
49
|
async function exists(p) {
|
|
45
50
|
return fs.access(p).then(() => true).catch(() => false);
|
|
46
51
|
}
|
|
@@ -49,10 +54,131 @@ async function copyDir(src, dest) {
|
|
|
49
54
|
await fs.cp(src, dest, { recursive: true });
|
|
50
55
|
}
|
|
51
56
|
|
|
52
|
-
|
|
57
|
+
// ─── Security hook wiring ────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
async function chooseSecurityHookMode({ target, yes, forced }) {
|
|
60
|
+
if (forced) return forced;
|
|
61
|
+
if (yes) return 'native';
|
|
62
|
+
|
|
63
|
+
const hasPackageJson = await exists(path.join(target, 'package.json'));
|
|
64
|
+
const hint = hasPackageJson
|
|
65
|
+
? ' Detected package.json — husky mode is available.'
|
|
66
|
+
: ' No package.json — husky mode will add one or fall back to native.';
|
|
67
|
+
|
|
68
|
+
console.log('\n Pre-commit security hook wiring:');
|
|
69
|
+
console.log(hint);
|
|
70
|
+
console.log(' [1] husky — shared via git, requires Node project');
|
|
71
|
+
console.log(' [2] native — plain .git/hooks/pre-commit, any stack');
|
|
72
|
+
console.log(' [3] skip — wire it later with "tas-kit install --security-hook=..."');
|
|
73
|
+
const answer = await ask(' Choose [1/2/3] (default: 2): ', '2');
|
|
74
|
+
if (answer === '1') return 'husky';
|
|
75
|
+
if (answer === '3') return 'none';
|
|
76
|
+
return 'native';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function installSecurityHookNative({ target }) {
|
|
80
|
+
const gitDir = path.join(target, '.git');
|
|
81
|
+
if (!(await exists(gitDir))) {
|
|
82
|
+
console.warn(' [skip] Security hook (native): .git/ not found — run `git init` first, then re-run installer with --security-hook=native');
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const hooksDir = path.join(gitDir, 'hooks');
|
|
87
|
+
await fs.mkdir(hooksDir, { recursive: true });
|
|
88
|
+
|
|
89
|
+
const src = path.join(PACKAGE_DIR, '.tas', 'hooks', 'pre-commit');
|
|
90
|
+
const dest = path.join(hooksDir, 'pre-commit');
|
|
91
|
+
|
|
92
|
+
if (await exists(dest)) {
|
|
93
|
+
const existing = await fs.readFile(dest, 'utf8').catch(() => '');
|
|
94
|
+
if (!existing.includes('TAS Kit')) {
|
|
95
|
+
const backup = dest + '.backup';
|
|
96
|
+
await fs.copyFile(dest, backup);
|
|
97
|
+
console.log(' [ok] .git/hooks/pre-commit.backup (preserved existing hook)');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
await fs.copyFile(src, dest);
|
|
102
|
+
if (process.platform !== 'win32') {
|
|
103
|
+
await fs.chmod(dest, 0o755);
|
|
104
|
+
}
|
|
105
|
+
console.log(' [ok] .git/hooks/pre-commit (security scan wired — native)');
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function installSecurityHookHusky({ target }) {
|
|
110
|
+
const pkgPath = path.join(target, 'package.json');
|
|
111
|
+
const hasPackageJson = await exists(pkgPath);
|
|
112
|
+
|
|
113
|
+
if (!hasPackageJson) {
|
|
114
|
+
console.warn(' [warn] Security hook (husky): no package.json — falling back to native mode');
|
|
115
|
+
return await installSecurityHookNative({ target });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Read + update package.json (add prepare script + husky devDep)
|
|
119
|
+
let pkg;
|
|
120
|
+
try {
|
|
121
|
+
pkg = JSON.parse(await fs.readFile(pkgPath, 'utf8'));
|
|
122
|
+
} catch (err) {
|
|
123
|
+
console.warn(` [warn] Security hook (husky): package.json unreadable (${err.message}) — falling back to native`);
|
|
124
|
+
return await installSecurityHookNative({ target });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
pkg.scripts = pkg.scripts || {};
|
|
128
|
+
if (!pkg.scripts.prepare) {
|
|
129
|
+
pkg.scripts.prepare = 'husky';
|
|
130
|
+
} else if (!/husky/.test(pkg.scripts.prepare)) {
|
|
131
|
+
pkg.scripts.prepare = `${pkg.scripts.prepare} && husky`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
pkg.devDependencies = pkg.devDependencies || {};
|
|
135
|
+
if (!pkg.devDependencies.husky) {
|
|
136
|
+
pkg.devDependencies.husky = '^9.1.0';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
|
140
|
+
console.log(' [ok] package.json (husky devDep + prepare script)');
|
|
141
|
+
|
|
142
|
+
// Write .husky/pre-commit
|
|
143
|
+
const huskyDir = path.join(target, '.husky');
|
|
144
|
+
await fs.mkdir(huskyDir, { recursive: true });
|
|
145
|
+
|
|
146
|
+
const huskyHook = path.join(huskyDir, 'pre-commit');
|
|
147
|
+
const content = [
|
|
148
|
+
'# TAS Kit — husky pre-commit (delegates to .tas/hooks/pre-commit)',
|
|
149
|
+
'. "$(dirname -- "$0")/../.tas/hooks/pre-commit"',
|
|
150
|
+
'',
|
|
151
|
+
].join('\n');
|
|
152
|
+
await fs.writeFile(huskyHook, content);
|
|
153
|
+
if (process.platform !== 'win32') {
|
|
154
|
+
await fs.chmod(huskyHook, 0o755);
|
|
155
|
+
}
|
|
156
|
+
console.log(' [ok] .husky/pre-commit (delegates to .tas/hooks/pre-commit)');
|
|
157
|
+
console.log(' [--] Run `npm install` in the project to activate husky');
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function installSecurityHook({ target, mode }) {
|
|
162
|
+
if (mode === 'none' || !mode) {
|
|
163
|
+
console.log(' [skip] Security hook (you can enable later: tas-kit install --security-hook=native|husky)');
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (mode === 'husky') {
|
|
167
|
+
await installSecurityHookHusky({ target });
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (mode === 'native') {
|
|
171
|
+
await installSecurityHookNative({ target });
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
console.warn(` [warn] Unknown security hook mode: "${mode}" — skipping`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ─── Update ──────────────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
export async function update({ directory, yes, securityHook }) {
|
|
53
180
|
const target = path.resolve(directory);
|
|
54
181
|
|
|
55
|
-
// Must already have .claude/ or .tas/ — otherwise suggest install
|
|
56
182
|
const claudeExists = await exists(path.join(target, '.claude'));
|
|
57
183
|
const tasExists = await exists(path.join(target, '.tas'));
|
|
58
184
|
if (!claudeExists && !tasExists) {
|
|
@@ -74,21 +200,12 @@ export async function update({ directory, yes }) {
|
|
|
74
200
|
console.log();
|
|
75
201
|
}
|
|
76
202
|
|
|
77
|
-
|
|
78
|
-
await copyDir(
|
|
79
|
-
path.join(PACKAGE_DIR, '.claude'),
|
|
80
|
-
path.join(target, '.claude')
|
|
81
|
-
);
|
|
203
|
+
await copyDir(path.join(PACKAGE_DIR, '.claude'), path.join(target, '.claude'));
|
|
82
204
|
console.log(' [ok] .claude/ (updated)');
|
|
83
205
|
|
|
84
|
-
|
|
85
|
-
await copyDir(
|
|
86
|
-
path.join(PACKAGE_DIR, '.tas'),
|
|
87
|
-
path.join(target, '.tas')
|
|
88
|
-
);
|
|
206
|
+
await copyDir(path.join(PACKAGE_DIR, '.tas'), path.join(target, '.tas'));
|
|
89
207
|
console.log(' [ok] .tas/ (updated)');
|
|
90
208
|
|
|
91
|
-
// Remove files deleted from the kit in previous versions
|
|
92
209
|
const deletedFiles = await getDeletedFiles();
|
|
93
210
|
if (deletedFiles.length > 0) {
|
|
94
211
|
const { removed } = await removeDeletedFiles(target, deletedFiles);
|
|
@@ -99,12 +216,20 @@ export async function update({ directory, yes }) {
|
|
|
99
216
|
}
|
|
100
217
|
}
|
|
101
218
|
|
|
102
|
-
// Set executable bit on tas-ado.py (Unix/macOS)
|
|
103
219
|
if (process.platform !== 'win32') {
|
|
104
220
|
const adoPy = path.join(target, '.tas', 'tools', 'tas-ado.py');
|
|
105
221
|
if (await exists(adoPy)) {
|
|
106
222
|
await fs.chmod(adoPy, 0o755);
|
|
107
223
|
}
|
|
224
|
+
const preCommit = path.join(target, '.tas', 'hooks', 'pre-commit');
|
|
225
|
+
if (await exists(preCommit)) {
|
|
226
|
+
await fs.chmod(preCommit, 0o755);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Re-wire hook only if user explicitly asked via flag
|
|
231
|
+
if (securityHook) {
|
|
232
|
+
await installSecurityHook({ target, mode: securityHook });
|
|
108
233
|
}
|
|
109
234
|
|
|
110
235
|
console.log(` [--] CLAUDE.md, tas.yaml, .env.example — not touched`);
|
|
@@ -117,15 +242,15 @@ and manually merge changes into your CLAUDE.md and tas.yaml if needed.
|
|
|
117
242
|
`);
|
|
118
243
|
}
|
|
119
244
|
|
|
120
|
-
|
|
245
|
+
// ─── Install ─────────────────────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
export async function install({ directory, yes, securityHook }) {
|
|
121
248
|
const target = path.resolve(directory);
|
|
122
249
|
|
|
123
|
-
// Ensure target directory exists
|
|
124
250
|
await fs.mkdir(target, { recursive: true });
|
|
125
251
|
|
|
126
252
|
console.log(`\nInstalling TAS Kit into: ${target}\n`);
|
|
127
253
|
|
|
128
|
-
// Warn if .claude/ or .tas/ already exist
|
|
129
254
|
const claudeExists = await exists(path.join(target, '.claude'));
|
|
130
255
|
const tasExists = await exists(path.join(target, '.tas'));
|
|
131
256
|
|
|
@@ -141,64 +266,51 @@ export async function install({ directory, yes }) {
|
|
|
141
266
|
console.log();
|
|
142
267
|
}
|
|
143
268
|
|
|
144
|
-
|
|
145
|
-
await copyDir(
|
|
146
|
-
path.join(PACKAGE_DIR, '.claude'),
|
|
147
|
-
path.join(target, '.claude')
|
|
148
|
-
);
|
|
269
|
+
await copyDir(path.join(PACKAGE_DIR, '.claude'), path.join(target, '.claude'));
|
|
149
270
|
console.log(' [ok] .claude/');
|
|
150
271
|
|
|
151
|
-
|
|
152
|
-
await copyDir(
|
|
153
|
-
path.join(PACKAGE_DIR, '.tas'),
|
|
154
|
-
path.join(target, '.tas')
|
|
155
|
-
);
|
|
272
|
+
await copyDir(path.join(PACKAGE_DIR, '.tas'), path.join(target, '.tas'));
|
|
156
273
|
console.log(' [ok] .tas/');
|
|
157
274
|
|
|
158
|
-
// Copy CLAUDE-Example.md as CLAUDE.md (only if absent)
|
|
159
275
|
const claudeMdTarget = path.join(target, 'CLAUDE.md');
|
|
160
276
|
if (!(await exists(claudeMdTarget))) {
|
|
161
|
-
await fs.copyFile(
|
|
162
|
-
path.join(PACKAGE_DIR, 'CLAUDE-Example.md'),
|
|
163
|
-
claudeMdTarget
|
|
164
|
-
);
|
|
277
|
+
await fs.copyFile(path.join(PACKAGE_DIR, 'CLAUDE-Example.md'), claudeMdTarget);
|
|
165
278
|
console.log(' [ok] CLAUDE.md (from CLAUDE-Example.md)');
|
|
166
279
|
} else {
|
|
167
280
|
console.log(' [--] CLAUDE.md already exists, skipped');
|
|
168
281
|
}
|
|
169
282
|
|
|
170
|
-
// Copy .env.example (only if absent)
|
|
171
283
|
const envExampleTarget = path.join(target, '.env.example');
|
|
172
284
|
if (!(await exists(envExampleTarget))) {
|
|
173
|
-
await fs.copyFile(
|
|
174
|
-
path.join(PACKAGE_DIR, '.env.example'),
|
|
175
|
-
envExampleTarget
|
|
176
|
-
);
|
|
285
|
+
await fs.copyFile(path.join(PACKAGE_DIR, '.env.example'), envExampleTarget);
|
|
177
286
|
console.log(' [ok] .env.example');
|
|
178
287
|
} else {
|
|
179
288
|
console.log(' [--] .env.example already exists, skipped');
|
|
180
289
|
}
|
|
181
290
|
|
|
182
|
-
// Copy tas-example.yaml as tas.yaml (only if absent)
|
|
183
291
|
const tasYamlTarget = path.join(target, 'tas.yaml');
|
|
184
292
|
if (!(await exists(tasYamlTarget))) {
|
|
185
|
-
await fs.copyFile(
|
|
186
|
-
path.join(PACKAGE_DIR, '.tas', 'tas-example.yaml'),
|
|
187
|
-
tasYamlTarget
|
|
188
|
-
);
|
|
293
|
+
await fs.copyFile(path.join(PACKAGE_DIR, '.tas', 'tas-example.yaml'), tasYamlTarget);
|
|
189
294
|
console.log(' [ok] tas.yaml (from .tas/tas-example.yaml)');
|
|
190
295
|
} else {
|
|
191
296
|
console.log(' [--] tas.yaml already exists, skipped');
|
|
192
297
|
}
|
|
193
298
|
|
|
194
|
-
// Set executable bit on tas-ado.py (Unix/macOS)
|
|
195
299
|
if (process.platform !== 'win32') {
|
|
196
300
|
const adoPy = path.join(target, '.tas', 'tools', 'tas-ado.py');
|
|
197
301
|
if (await exists(adoPy)) {
|
|
198
302
|
await fs.chmod(adoPy, 0o755);
|
|
199
303
|
}
|
|
304
|
+
const preCommit = path.join(target, '.tas', 'hooks', 'pre-commit');
|
|
305
|
+
if (await exists(preCommit)) {
|
|
306
|
+
await fs.chmod(preCommit, 0o755);
|
|
307
|
+
}
|
|
200
308
|
}
|
|
201
309
|
|
|
310
|
+
// ─── Wire security pre-commit hook ─────────────────────────────────────────
|
|
311
|
+
const mode = await chooseSecurityHookMode({ target, yes, forced: securityHook });
|
|
312
|
+
await installSecurityHook({ target, mode });
|
|
313
|
+
|
|
202
314
|
console.log(`
|
|
203
315
|
TAS Kit installed successfully!
|
|
204
316
|
|
|
@@ -208,6 +320,8 @@ Next steps:
|
|
|
208
320
|
3. Create .env — add AZURE_DEVOPS_PAT (see .env.example)
|
|
209
321
|
4. Open Claude Code — run /tas-init to initialize your project
|
|
210
322
|
|
|
211
|
-
Docs:
|
|
323
|
+
Docs:
|
|
324
|
+
.tas/README.md — kit overview
|
|
325
|
+
.tas/hooks/README.md — pre-commit security hook details
|
|
212
326
|
`);
|
|
213
327
|
}
|
package/package.json
CHANGED
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
# /tas-api-test $ARGUMENTS
|
|
2
|
-
|
|
3
|
-
Vai trò: SE - Software Engineer
|
|
4
|
-
Generate .NET xUnit automation test project cho REST API từ spec (OpenAPI 3.0, Markdown, YAML).
|
|
5
|
-
|
|
6
|
-
## Always / Ask / Never
|
|
7
|
-
|
|
8
|
-
| | Hành động |
|
|
9
|
-
|---|---|
|
|
10
|
-
| **Always** | Đọc toàn bộ spec trước khi generate bất kỳ test nào |
|
|
11
|
-
| **Always** | Tổ chức tests theo API version — mỗi version một folder riêng |
|
|
12
|
-
| **Always** | Append-only: không sửa files trong version folder cũ đã tồn tại |
|
|
13
|
-
| **Always** | URL và credentials ra `appsettings.json` — không hardcode trong code |
|
|
14
|
-
| **Always** | XML doc comment trên mỗi test method |
|
|
15
|
-
| **Ask** | Khi spec không rõ expected response schema |
|
|
16
|
-
| **Never** | Sửa hoặc xóa test files của version cũ hơn |
|
|
17
|
-
| **Never** | Hardcode base URL, API key, credentials trong test code |
|
|
18
|
-
| **Never** | Bỏ qua error path — mỗi endpoint cần ≥1 happy path + ≥1 error path |
|
|
19
|
-
|
|
20
|
-
## Hành động
|
|
21
|
-
|
|
22
|
-
### Bước 1 — Locate Inputs
|
|
23
|
-
|
|
24
|
-
`$ARGUMENTS` là path đến spec file hoặc Story ID. Nếu không có → hỏi user.
|
|
25
|
-
|
|
26
|
-
Nếu là Story ID: glob tìm `docs/epics/**/Story-{ID}*.md`, đọc Story để tìm link spec trong `## Technical Plan` hoặc `## Acceptance Criteria`.
|
|
27
|
-
|
|
28
|
-
### Bước 2 — Parse API Spec
|
|
29
|
-
|
|
30
|
-
Đọc `.claude/rules/csharp/api-testing.md` để nắm convention trước khi generate.
|
|
31
|
-
|
|
32
|
-
Từ spec, thu thập cho mỗi endpoint:
|
|
33
|
-
- HTTP method + path, path/query params, request body schema
|
|
34
|
-
- Response schemas theo từng status code
|
|
35
|
-
- Auth requirement, description/summary
|
|
36
|
-
|
|
37
|
-
Nếu có Story: đọc thêm `## Acceptance Criteria` để bổ sung business rule test cases.
|
|
38
|
-
|
|
39
|
-
### Bước 3 — Detect Existing Test Project
|
|
40
|
-
|
|
41
|
-
Glob tìm `**/ApiTests/*.csproj`, `**/Api.Tests/*.csproj`, `**/IntegrationTests/*.csproj`.
|
|
42
|
-
|
|
43
|
-
- **Tìm thấy**: Đọc framework đang dùng (xUnit/NUnit/MSTest), glob `v*/` folders để biết versions đã có.
|
|
44
|
-
- **Không tìm thấy**: Tạo mới tại `tests/ApiTests/` với xUnit + FluentAssertions (per `csharp/testing.md`).
|
|
45
|
-
|
|
46
|
-
### Bước 4 — Detect API Version
|
|
47
|
-
|
|
48
|
-
Ưu tiên theo thứ tự: `info.version` trong OpenAPI → prefix trong URL path → hỏi user.
|
|
49
|
-
Version folder: lowercase, không có dot — `v1`, `v2` (không phải `v1.0`).
|
|
50
|
-
|
|
51
|
-
### Bước 5 — Generate Infrastructure (chỉ khi tạo project mới)
|
|
52
|
-
|
|
53
|
-
Đọc `.claude/rules/csharp/api-testing.md` section **Project Structure** và **Config Pattern** để generate:
|
|
54
|
-
- `ApiTests.csproj` với packages chuẩn
|
|
55
|
-
- `appsettings.json` (base) + `appsettings.Test.json` + `appsettings.Staging.json`
|
|
56
|
-
- `Shared/TestBase.cs` — load config, HttpClient, helper methods
|
|
57
|
-
- `Shared/ApiTestSettings.cs` — strongly typed settings
|
|
58
|
-
- `.gitignore` cho `appsettings.*.local.json`
|
|
59
|
-
|
|
60
|
-
### Bước 6 — Generate Test Classes
|
|
61
|
-
|
|
62
|
-
Nhóm endpoints theo resource/tag. Mỗi nhóm → `tests/ApiTests/{version}/{Resource}ApiTests.cs`.
|
|
63
|
-
|
|
64
|
-
Với mỗi endpoint, generate tối thiểu:
|
|
65
|
-
1. Happy path (2xx)
|
|
66
|
-
2. Unauthorized (401) — nếu endpoint yêu cầu auth
|
|
67
|
-
3. Not found (404) — nếu có path param
|
|
68
|
-
4. Validation error (400/422) — nếu có required fields
|
|
69
|
-
|
|
70
|
-
Từ Story ACs: thêm test methods tương ứng, comment rõ `// AC: {AC text}`.
|
|
71
|
-
|
|
72
|
-
Đầu mỗi file: block comment ghi `Spec`, `Generated date`, `Story ID`, và nhắc **APPEND-ONLY rule**.
|
|
73
|
-
|
|
74
|
-
### Bước 7 — Append-Only Guard
|
|
75
|
-
|
|
76
|
-
Trước khi ghi file: kiểm tra file đã tồn tại chưa.
|
|
77
|
-
- File chưa có → tạo mới bình thường.
|
|
78
|
-
- File đã tồn tại trong version folder → **DỪNG**, thông báo tên file cho user tự edit thủ công.
|
|
79
|
-
|
|
80
|
-
### Bước 8 — Post-Generate Review
|
|
81
|
-
|
|
82
|
-
Launch `csharp-reviewer` agent:
|
|
83
|
-
> Review `tests/ApiTests/{version}/`. Đọc `.claude/rules/csharp/testing.md` + `.claude/rules/csharp/api-testing.md`.
|
|
84
|
-
> Tập trung: async/await, HttpClient disposal, assertion completeness, XML doc coverage.
|
|
85
|
-
> Format: Critical / High / Medium / Low với file:line.
|
|
86
|
-
|
|
87
|
-
Gate: Critical/High → fix ngay. Medium/Low → list gợi ý.
|
|
88
|
-
|
|
89
|
-
### Bước 9 — Summary
|
|
90
|
-
|
|
91
|
-
Hiển thị bảng coverage (endpoint × test type: ✓/—) và next steps để chạy tests.
|
|
92
|
-
|
|
93
|
-
## Bước cuối — Token Log
|
|
94
|
-
|
|
95
|
-
Invoke skill `token-logger`: ghi AI Usage Log vào spec file hoặc Story đang làm việc.
|