dw-kit 1.3.6 → 1.4.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/skills/dw-archive/SKILL.md +14 -0
- package/.claude/skills/dw-review/SKILL.md +33 -2
- package/.dw/config/config.schema.json +149 -121
- package/.dw/config/dw.config.yml +14 -0
- package/README.md +1 -0
- package/package.json +2 -1
- package/src/cli.mjs +15 -0
- package/src/commands/doctor.mjs +41 -1
- package/src/commands/review-render.mjs +255 -0
- package/src/lib/config.mjs +120 -104
- package/src/lib/review/manifest-schema.json +149 -0
- package/src/lib/review/manifest-validator.mjs +93 -0
- package/src/lib/review/scope-slug.mjs +68 -0
|
@@ -54,6 +54,20 @@ mv {paths.tasks}/[task-name] {paths.tasks}/archive/[YYYY-MM]/
|
|
|
54
54
|
|
|
55
55
|
Tổ chức theo tháng hoàn thành để dễ tìm kiếm sau.
|
|
56
56
|
|
|
57
|
+
### 4a. Clean up review render artifacts (ADR-0007)
|
|
58
|
+
|
|
59
|
+
Nếu `.dw/reviews/[task-name]/` hoặc `.dw/reviews/[task-slug]/` tồn tại:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
# Local-only ephemeral artifacts (gitignored) — safe to remove on archive.
|
|
63
|
+
# User can regenerate via /dw:review --visual nếu cần lại.
|
|
64
|
+
rm -rf .dw/reviews/[task-name]/
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Báo cáo trong output Bước 6: "Đã dọn .dw/reviews/[task-name]/ (regenerate via /dw:review --visual nếu cần)."
|
|
68
|
+
|
|
69
|
+
Nếu task có scope_slug khác task-name (kiểm tra `.dw/reviews/*/manifest.json` có `task_id` matching), dọn cả slug đó.
|
|
70
|
+
|
|
57
71
|
## Bước 5: Cập nhật archive index
|
|
58
72
|
|
|
59
73
|
Ghi/cập nhật `{paths.tasks}/archive/README.md`:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: dw:review
|
|
3
|
-
description: "Review code thay đổi gần đây hoặc cả task. Kiểm tra correctness, security, conventions, test coverage. Tạo báo cáo phân loại Critical/Warning/Suggestion."
|
|
4
|
-
argument-hint: "[task-name | branch | file]"
|
|
3
|
+
description: "Review code thay đổi gần đây hoặc cả task. Kiểm tra correctness, security, conventions, test coverage. Tạo báo cáo phân loại Critical/Warning/Suggestion. Pass --visual để emit manifest cho visual artifacts (ADR-0007)."
|
|
4
|
+
argument-hint: "[task-name | branch | file] [--visual]"
|
|
5
5
|
context: fork
|
|
6
6
|
agent: reviewer
|
|
7
7
|
allowed-tools:
|
|
@@ -11,6 +11,8 @@ allowed-tools:
|
|
|
11
11
|
- "Bash(git diff *)"
|
|
12
12
|
- "Bash(git log *)"
|
|
13
13
|
- "Bash(git show *)"
|
|
14
|
+
- "Bash(dw review render *)"
|
|
15
|
+
- "Write(.dw/reviews/**/manifest.json)"
|
|
14
16
|
---
|
|
15
17
|
|
|
16
18
|
# Code Review
|
|
@@ -57,6 +59,35 @@ Nếu có plan file (`{paths.tasks}/$ARGUMENTS/$ARGUMENTS-plan.md`):
|
|
|
57
59
|
|
|
58
60
|
Tạo báo cáo đầy đủ theo format của reviewer agent.
|
|
59
61
|
|
|
62
|
+
### 5-alt. `--visual` flag (ADR-0007)
|
|
63
|
+
|
|
64
|
+
Nếu user pass `--visual`, KHÔNG in báo cáo inline. Thay vào đó:
|
|
65
|
+
|
|
66
|
+
1. **Tạo manifest JSON** tuân thủ `src/lib/review/manifest-schema.json` (schema_version 1):
|
|
67
|
+
- `scope`: nhãn review (branch name, task slug, hoặc free-form từ argument)
|
|
68
|
+
- `scope_slug`: sanitize qua `scope-slug` util — KHÔNG dùng scope thô làm tên thư mục
|
|
69
|
+
- `generated_at`: ISO timestamp hiện tại
|
|
70
|
+
- `task_id` (optional): nếu review thuộc một task — link tới `.dw/tasks/{id}/`
|
|
71
|
+
- `review_meta`: `{reviewer: "dw-review", depth, diff_base, files_reviewed}`
|
|
72
|
+
- `findings[]`: mỗi finding có `id, severity (critical|warning|suggestion), title, location {file, line_start, line_end}, rule_ref?, body, fix?, code_snippet (≤50 lines quanh finding), language?`
|
|
73
|
+
|
|
74
|
+
2. **Ghi manifest** ra `.dw/reviews/{scope_slug}/manifest.json` qua Write tool. Đây là file DUY NHẤT skill được phép viết.
|
|
75
|
+
|
|
76
|
+
3. **Gọi renderer**:
|
|
77
|
+
```bash
|
|
78
|
+
dw review render .dw/reviews/{scope_slug}/manifest.json
|
|
79
|
+
```
|
|
80
|
+
CLI sẽ:
|
|
81
|
+
- Validate manifest qua schema
|
|
82
|
+
- Phát hiện `dw-kit-render` package (optional sub-package)
|
|
83
|
+
- Nếu có: render SVG + PNG per finding → `.dw/reviews/{scope_slug}/finding-{id}.svg` + `.png`
|
|
84
|
+
- Nếu thiếu: ghi `summary.md` markdown + prompt user install `dw-kit-render`
|
|
85
|
+
- Luôn ghi `summary.md` tổng hợp với links tới artifacts
|
|
86
|
+
|
|
87
|
+
4. **Surface kết quả**: in danh sách artifact paths cho user; gợi ý mở `summary.md` hoặc embed image vào PR comment.
|
|
88
|
+
|
|
89
|
+
KHÔNG fallback inline report — manifest + render là contract của `--visual`.
|
|
90
|
+
|
|
60
91
|
## Sau Review
|
|
61
92
|
|
|
62
93
|
- Nếu có Critical issues: "Cần fix trước khi merge"
|
|
@@ -1,121 +1,149 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
-
"title": "dw-kit Configuration Schema",
|
|
4
|
-
"description": "Schema for config/dw.config.yml",
|
|
5
|
-
"type": "object",
|
|
6
|
-
"additionalProperties": false,
|
|
7
|
-
"properties": {
|
|
8
|
-
"project": {
|
|
9
|
-
"type": "object",
|
|
10
|
-
"additionalProperties": false,
|
|
11
|
-
"required": ["name"],
|
|
12
|
-
"properties": {
|
|
13
|
-
"name": { "type": "string", "minLength": 1 },
|
|
14
|
-
"language": { "type": "string", "enum": ["vi", "en"], "default": "vi" }
|
|
15
|
-
}
|
|
16
|
-
},
|
|
17
|
-
"workflow": {
|
|
18
|
-
"type": "object",
|
|
19
|
-
"additionalProperties": false,
|
|
20
|
-
"properties": {
|
|
21
|
-
"default_depth": {
|
|
22
|
-
"type": "string",
|
|
23
|
-
"enum": ["quick", "standard", "thorough"],
|
|
24
|
-
"default": "standard"
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
},
|
|
28
|
-
"team": {
|
|
29
|
-
"type": "object",
|
|
30
|
-
"additionalProperties": false,
|
|
31
|
-
"properties": {
|
|
32
|
-
"roles": {
|
|
33
|
-
"type": "array",
|
|
34
|
-
"items": {
|
|
35
|
-
"type": "string",
|
|
36
|
-
"enum": ["dev", "techlead", "ba", "qc", "pm"]
|
|
37
|
-
},
|
|
38
|
-
"minItems": 1,
|
|
39
|
-
"contains": { "const": "dev" },
|
|
40
|
-
"description": "dev is always required"
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
},
|
|
44
|
-
"quality": {
|
|
45
|
-
"type": "object",
|
|
46
|
-
"additionalProperties": false,
|
|
47
|
-
"properties": {
|
|
48
|
-
"test_command": { "type": "string", "default": "" },
|
|
49
|
-
"lint_command": { "type": "string", "default": "" },
|
|
50
|
-
"block_on_fail": { "type": "boolean", "default": false }
|
|
51
|
-
}
|
|
52
|
-
},
|
|
53
|
-
"tracking": {
|
|
54
|
-
"type": "object",
|
|
55
|
-
"additionalProperties": false,
|
|
56
|
-
"properties": {
|
|
57
|
-
"estimation": { "type": "boolean", "default": false },
|
|
58
|
-
"log_work": { "type": "boolean", "default": false },
|
|
59
|
-
"estimation_unit": {
|
|
60
|
-
"type": "string",
|
|
61
|
-
"enum": ["hours", "story-points", "t-shirt"],
|
|
62
|
-
"default": "hours"
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
},
|
|
66
|
-
"paths": {
|
|
67
|
-
"type": "object",
|
|
68
|
-
"additionalProperties": false,
|
|
69
|
-
"properties": {
|
|
70
|
-
"tasks": { "type": "string", "default": ".dw/tasks" },
|
|
71
|
-
"docs": { "type": "string", "default": ".dw/docs" }
|
|
72
|
-
}
|
|
73
|
-
},
|
|
74
|
-
"claude": {
|
|
75
|
-
"type": "object",
|
|
76
|
-
"additionalProperties": false,
|
|
77
|
-
"properties": {
|
|
78
|
-
"models": {
|
|
79
|
-
"type": "object",
|
|
80
|
-
"additionalProperties": false,
|
|
81
|
-
"properties": {
|
|
82
|
-
"plan": { "type": "string", "default": "" },
|
|
83
|
-
"execute": { "type": "string", "default": "" },
|
|
84
|
-
"review": { "type": "string", "default": "" }
|
|
85
|
-
}
|
|
86
|
-
},
|
|
87
|
-
"structured_output": { "type": "boolean", "default": true },
|
|
88
|
-
"worktree_execution": { "type": "boolean", "default": false },
|
|
89
|
-
"
|
|
90
|
-
"type": "
|
|
91
|
-
"
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
"
|
|
95
|
-
|
|
96
|
-
"
|
|
97
|
-
"
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
"
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"title": "dw-kit Configuration Schema",
|
|
4
|
+
"description": "Schema for config/dw.config.yml",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"properties": {
|
|
8
|
+
"project": {
|
|
9
|
+
"type": "object",
|
|
10
|
+
"additionalProperties": false,
|
|
11
|
+
"required": ["name"],
|
|
12
|
+
"properties": {
|
|
13
|
+
"name": { "type": "string", "minLength": 1 },
|
|
14
|
+
"language": { "type": "string", "enum": ["vi", "en"], "default": "vi" }
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"workflow": {
|
|
18
|
+
"type": "object",
|
|
19
|
+
"additionalProperties": false,
|
|
20
|
+
"properties": {
|
|
21
|
+
"default_depth": {
|
|
22
|
+
"type": "string",
|
|
23
|
+
"enum": ["quick", "standard", "thorough"],
|
|
24
|
+
"default": "standard"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"team": {
|
|
29
|
+
"type": "object",
|
|
30
|
+
"additionalProperties": false,
|
|
31
|
+
"properties": {
|
|
32
|
+
"roles": {
|
|
33
|
+
"type": "array",
|
|
34
|
+
"items": {
|
|
35
|
+
"type": "string",
|
|
36
|
+
"enum": ["dev", "techlead", "ba", "qc", "pm"]
|
|
37
|
+
},
|
|
38
|
+
"minItems": 1,
|
|
39
|
+
"contains": { "const": "dev" },
|
|
40
|
+
"description": "dev is always required"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"quality": {
|
|
45
|
+
"type": "object",
|
|
46
|
+
"additionalProperties": false,
|
|
47
|
+
"properties": {
|
|
48
|
+
"test_command": { "type": "string", "default": "" },
|
|
49
|
+
"lint_command": { "type": "string", "default": "" },
|
|
50
|
+
"block_on_fail": { "type": "boolean", "default": false }
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"tracking": {
|
|
54
|
+
"type": "object",
|
|
55
|
+
"additionalProperties": false,
|
|
56
|
+
"properties": {
|
|
57
|
+
"estimation": { "type": "boolean", "default": false },
|
|
58
|
+
"log_work": { "type": "boolean", "default": false },
|
|
59
|
+
"estimation_unit": {
|
|
60
|
+
"type": "string",
|
|
61
|
+
"enum": ["hours", "story-points", "t-shirt"],
|
|
62
|
+
"default": "hours"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
"paths": {
|
|
67
|
+
"type": "object",
|
|
68
|
+
"additionalProperties": false,
|
|
69
|
+
"properties": {
|
|
70
|
+
"tasks": { "type": "string", "default": ".dw/tasks" },
|
|
71
|
+
"docs": { "type": "string", "default": ".dw/docs" }
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
"claude": {
|
|
75
|
+
"type": "object",
|
|
76
|
+
"additionalProperties": false,
|
|
77
|
+
"properties": {
|
|
78
|
+
"models": {
|
|
79
|
+
"type": "object",
|
|
80
|
+
"additionalProperties": false,
|
|
81
|
+
"properties": {
|
|
82
|
+
"plan": { "type": "string", "default": "" },
|
|
83
|
+
"execute": { "type": "string", "default": "" },
|
|
84
|
+
"review": { "type": "string", "default": "" }
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
"structured_output": { "type": "boolean", "default": true },
|
|
88
|
+
"worktree_execution": { "type": "boolean", "default": false },
|
|
89
|
+
"review": {
|
|
90
|
+
"type": "object",
|
|
91
|
+
"additionalProperties": false,
|
|
92
|
+
"description": "Review render pipeline (ADR-0007).",
|
|
93
|
+
"properties": {
|
|
94
|
+
"renderer": {
|
|
95
|
+
"type": "object",
|
|
96
|
+
"additionalProperties": false,
|
|
97
|
+
"properties": {
|
|
98
|
+
"strategy": {
|
|
99
|
+
"type": "string",
|
|
100
|
+
"enum": ["auto", "plugin", "markdown-only"],
|
|
101
|
+
"default": "auto"
|
|
102
|
+
},
|
|
103
|
+
"theme": { "type": "string", "default": "github-dark" },
|
|
104
|
+
"font": { "type": "string", "default": "JetBrains Mono" },
|
|
105
|
+
"formats": {
|
|
106
|
+
"type": "array",
|
|
107
|
+
"items": { "type": "string", "enum": ["svg", "png"] },
|
|
108
|
+
"minItems": 1,
|
|
109
|
+
"uniqueItems": true,
|
|
110
|
+
"default": ["svg", "png"]
|
|
111
|
+
},
|
|
112
|
+
"output_dir": { "type": "string", "default": ".dw/reviews" }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
"mcp": {
|
|
118
|
+
"type": "array",
|
|
119
|
+
"items": {
|
|
120
|
+
"type": "object",
|
|
121
|
+
"required": ["name", "command"],
|
|
122
|
+
"additionalProperties": false,
|
|
123
|
+
"properties": {
|
|
124
|
+
"name": { "type": "string" },
|
|
125
|
+
"command": { "type": "string" },
|
|
126
|
+
"args": { "type": "array", "items": { "type": "string" } },
|
|
127
|
+
"env": {
|
|
128
|
+
"type": "object",
|
|
129
|
+
"additionalProperties": { "type": "string" }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
"_toolkit": {
|
|
137
|
+
"type": "object",
|
|
138
|
+
"additionalProperties": true,
|
|
139
|
+
"properties": {
|
|
140
|
+
"core_version": { "type": "string" },
|
|
141
|
+
"platform_version": { "type": "string" },
|
|
142
|
+
"capability_version": { "type": "string" },
|
|
143
|
+
"installed": { "type": "string" },
|
|
144
|
+
"last_upgrade": { "type": "string" },
|
|
145
|
+
"migrated_from": { "type": "string" }
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
package/.dw/config/dw.config.yml
CHANGED
|
@@ -59,6 +59,20 @@ claude:
|
|
|
59
59
|
# Isolate execution trong git worktree (cho risky refactors)
|
|
60
60
|
worktree_execution: false
|
|
61
61
|
|
|
62
|
+
# --- Review render pipeline (ADR-0007) ------------------------------------
|
|
63
|
+
# /dw:review --visual produces a manifest.json then invokes `dw review render`.
|
|
64
|
+
# Output goes to .dw/reviews/{scope-slug}/ — see ADR-0007.
|
|
65
|
+
review:
|
|
66
|
+
renderer:
|
|
67
|
+
# auto: try dw-kit-render package, fall back to markdown summary if missing
|
|
68
|
+
# plugin: require dw-kit-render to be installed (fail if missing)
|
|
69
|
+
# markdown-only: never invoke a renderer, emit markdown summary only
|
|
70
|
+
strategy: "auto"
|
|
71
|
+
theme: "github-dark" # any shiki theme name
|
|
72
|
+
font: "JetBrains Mono" # font family for code in SVG
|
|
73
|
+
formats: ["svg", "png"] # outputs produced per finding
|
|
74
|
+
output_dir: ".dw/reviews"
|
|
75
|
+
|
|
62
76
|
# MCP servers — Claude Code sẽ load các servers này
|
|
63
77
|
mcp: []
|
|
64
78
|
# Ví dụ:
|
package/README.md
CHANGED
|
@@ -36,6 +36,7 @@ It’s designed for collaboration (Dev / Tech Lead / QA / PM) and keeps work aud
|
|
|
36
36
|
|
|
37
37
|
## Release notes
|
|
38
38
|
|
|
39
|
+
- **v1.4 (in progress)** — Optional **Review Render Pipeline** ([ADR-0007](.dw/decisions/0007-decoupled-review-render-pipeline.md)): `/dw:review --visual` plus a separate `dw-kit-render` package turn findings into SVG + PNG cards for PR comments / Slack / stakeholders. Pure JS + WASM, universal `npm install`, no system deps. See [`docs/review-renderer.md`](docs/review-renderer.md).
|
|
39
40
|
- **v1.3.6** (2026-05-14) — Supply-Chain Guard upgraded to 3-pillar architecture: OSV snapshot + curated IoC fixture (version-aware, wired into default scan) + **AI-Native NEW-package heuristic** that catches zero-day-ish risk at the AI-edit boundary. See [`CHANGELOG.md#v136--2026-05-14`](CHANGELOG.md#v136--2026-05-14) and [ADR-0006](.dw/decisions/0006-supply-chain-guard-heuristic.md).
|
|
40
41
|
- v1.3.5 (2026-05-12) — AI-Native Supply-Chain Guard: `dw security-scan` CLI + OSV.dev auto-sync + Edit-lockfile hook + scoped `.gitignore` for end-user projects. See [ADR-0005](.dw/decisions/0005-supply-chain-guard.md). Public 90-day sunset review committed for 2026-08-12.
|
|
41
42
|
- v1.3.4 (2026-04-21) — `/dw:plan` Quick Debate (red/blue self-critique), depth-driven activation
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dw-kit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "AI development workflow toolkit — structured, quality-assured, team-ready. From requirements to dashboard.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
},
|
|
57
57
|
"scripts": {
|
|
58
58
|
"test": "node src/smoke-test.mjs",
|
|
59
|
+
"test:renderer": "cd packages/dw-kit-render && npm test",
|
|
59
60
|
"link": "npm link",
|
|
60
61
|
"test:e2e-local": "bash scripts/e2e-local-check.sh"
|
|
61
62
|
},
|
package/src/cli.mjs
CHANGED
|
@@ -131,6 +131,21 @@ export function run(argv) {
|
|
|
131
131
|
await securityScanCommand(opts);
|
|
132
132
|
});
|
|
133
133
|
|
|
134
|
+
const reviewCmd = program
|
|
135
|
+
.command('review')
|
|
136
|
+
.description('Review subcommands (ADR-0007)');
|
|
137
|
+
|
|
138
|
+
reviewCmd
|
|
139
|
+
.command('render <manifest>')
|
|
140
|
+
.description('Render a /dw:review --visual manifest into SVG/PNG (requires dw-kit-render) or markdown summary fallback')
|
|
141
|
+
.option('-f, --format <kind>', 'Override output formats: svg | png | both', null)
|
|
142
|
+
.option('-s, --strategy <name>', 'Override strategy: auto | plugin | markdown-only', null)
|
|
143
|
+
.option('-q, --quiet', 'Suppress info logs (still exits non-zero on hard errors)')
|
|
144
|
+
.action(async (manifest, opts) => {
|
|
145
|
+
const { reviewRenderCommand } = await import('./commands/review-render.mjs');
|
|
146
|
+
await reviewRenderCommand(manifest, opts);
|
|
147
|
+
});
|
|
148
|
+
|
|
134
149
|
program
|
|
135
150
|
.command('claude-vn-fix')
|
|
136
151
|
.description('Patch Claude CLI to fix Vietnamese IME (local, with backup/restore)')
|
package/src/commands/doctor.mjs
CHANGED
|
@@ -1,12 +1,29 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
2
3
|
import { join, resolve } from 'node:path';
|
|
3
4
|
import { fileURLToPath } from 'node:url';
|
|
4
5
|
import { header, ok, warn, err, info, log } from '../lib/ui.mjs';
|
|
5
|
-
import { loadConfig, getToolkitVersions } from '../lib/config.mjs';
|
|
6
|
+
import { loadConfig, getToolkitVersions, getReviewRendererConfig } from '../lib/config.mjs';
|
|
6
7
|
import { detectPlatform, platformLabel } from '../lib/platform.mjs';
|
|
7
8
|
import { snapshotInfo } from '../lib/sc-sync.mjs';
|
|
8
9
|
|
|
9
10
|
const TOOLKIT_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..', '..');
|
|
11
|
+
const RENDER_PACKAGE = 'dw-kit-render';
|
|
12
|
+
|
|
13
|
+
function tryResolveRenderer(projectDir) {
|
|
14
|
+
if (process.env.DW_REVIEW_NO_RENDERER === '1') return { ok: false, reason: 'disabled by DW_REVIEW_NO_RENDERER=1' };
|
|
15
|
+
const reqProject = createRequire(join(projectDir, 'package.json'));
|
|
16
|
+
for (const req of [reqProject, createRequire(import.meta.url)]) {
|
|
17
|
+
try {
|
|
18
|
+
const pkgPath = req.resolve(`${RENDER_PACKAGE}/package.json`);
|
|
19
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
20
|
+
return { ok: true, version: pkg.version };
|
|
21
|
+
} catch {
|
|
22
|
+
// try next
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return { ok: false };
|
|
26
|
+
}
|
|
10
27
|
|
|
11
28
|
const CORE_FILES = [
|
|
12
29
|
'.dw/core/WORKFLOW.md',
|
|
@@ -151,6 +168,29 @@ export async function doctorCommand() {
|
|
|
151
168
|
}
|
|
152
169
|
}
|
|
153
170
|
|
|
171
|
+
info('Review Render Pipeline (ADR-0007, opt-in)');
|
|
172
|
+
const rendererCfg = existsSync(configPath) ? getReviewRendererConfig(loadConfig(configPath) || {}) : getReviewRendererConfig({});
|
|
173
|
+
log(` Strategy : ${rendererCfg.strategy}`);
|
|
174
|
+
log(` Formats : ${rendererCfg.formats.join(', ')}`);
|
|
175
|
+
log(` Theme/Font : ${rendererCfg.theme} / ${rendererCfg.font}`);
|
|
176
|
+
|
|
177
|
+
if (rendererCfg.strategy === 'markdown-only') {
|
|
178
|
+
log(' Renderer : skipped (strategy=markdown-only)');
|
|
179
|
+
} else {
|
|
180
|
+
const r = tryResolveRenderer(projectDir);
|
|
181
|
+
if (r.ok) {
|
|
182
|
+
ok(`Renderer : dw-kit-render v${r.version || '?'} resolvable`);
|
|
183
|
+
} else if (rendererCfg.strategy === 'plugin') {
|
|
184
|
+
err("Renderer : 'dw-kit-render' NOT installed but strategy='plugin'");
|
|
185
|
+
log(` Install: npm install -g dw-kit-render`);
|
|
186
|
+
issues++;
|
|
187
|
+
} else {
|
|
188
|
+
warn(`Renderer : 'dw-kit-render' not installed — /dw:review --visual will fall back to markdown`);
|
|
189
|
+
log(` Install (optional): npm install -g dw-kit-render`);
|
|
190
|
+
warnings++;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
154
194
|
info('Supply-Chain Guard (ADR-0005, opt-in)');
|
|
155
195
|
const sc = snapshotInfo(projectDir);
|
|
156
196
|
if (!sc.exists) {
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join, resolve, isAbsolute } from 'node:path';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { performance } from 'node:perf_hooks';
|
|
6
|
+
import chalk from 'chalk';
|
|
7
|
+
import { header, ok, info, log, warn, err } from '../lib/ui.mjs';
|
|
8
|
+
import { loadConfigWithLocal, getReviewRendererConfig } from '../lib/config.mjs';
|
|
9
|
+
import { readManifest } from '../lib/review/manifest-validator.mjs';
|
|
10
|
+
import { logEvent } from '../lib/telemetry.mjs';
|
|
11
|
+
|
|
12
|
+
const RENDER_PACKAGE = 'dw-kit-render';
|
|
13
|
+
const INSTALL_HINT = ` Install renderer with: ${chalk.cyan('npm install -g dw-kit-render')}\n Or run: ${chalk.cyan('dw doctor')} for environment check.`;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `dw review render <manifest>` — invoked by /dw:review --visual after writing manifest.
|
|
17
|
+
* See ADR-0007 for architecture.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} manifestPath - path to manifest.json (relative or absolute)
|
|
20
|
+
* @param {{format?: string, strategy?: string, quiet?: boolean}} opts
|
|
21
|
+
*/
|
|
22
|
+
export async function reviewRenderCommand(manifestPath, opts = {}) {
|
|
23
|
+
const projectDir = process.cwd();
|
|
24
|
+
const absManifest = isAbsolute(manifestPath) ? manifestPath : resolve(projectDir, manifestPath);
|
|
25
|
+
const startedAt = performance.now();
|
|
26
|
+
|
|
27
|
+
if (!opts.quiet) header('dw review render');
|
|
28
|
+
|
|
29
|
+
// 1. Load + validate manifest.
|
|
30
|
+
const parseResult = readManifest(absManifest);
|
|
31
|
+
if (!parseResult.ok) {
|
|
32
|
+
err(`Manifest invalid: ${absManifest}`);
|
|
33
|
+
for (const e of parseResult.errors.slice(0, 10)) {
|
|
34
|
+
log(` ${chalk.dim(e.path || '/')} — ${e.message}`);
|
|
35
|
+
}
|
|
36
|
+
logEvent({ event: 'review_render', action: 'fail', fallback_reason: 'invalid-manifest' }, projectDir);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
const manifest = parseResult.manifest;
|
|
40
|
+
|
|
41
|
+
// 2. Resolve config + strategy.
|
|
42
|
+
const config = loadConfigWithLocal(join(projectDir, '.dw', 'config')) || {};
|
|
43
|
+
const rendererCfg = getReviewRendererConfig(config);
|
|
44
|
+
const strategy = opts.strategy || rendererCfg.strategy;
|
|
45
|
+
const formats = parseFormats(opts.format) || rendererCfg.formats;
|
|
46
|
+
|
|
47
|
+
// 3. Resolve output directory.
|
|
48
|
+
const outDir = resolveOutputDir(rendererCfg.output_dir, manifest, projectDir);
|
|
49
|
+
if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
|
|
50
|
+
|
|
51
|
+
if (!opts.quiet) {
|
|
52
|
+
info('Render context');
|
|
53
|
+
log(` Manifest : ${absManifest}`);
|
|
54
|
+
log(` Scope : ${manifest.scope} (slug: ${manifest.scope_slug || '—'})`);
|
|
55
|
+
log(` Findings : ${manifest.findings.length}`);
|
|
56
|
+
log(` Strategy : ${strategy}`);
|
|
57
|
+
log(` Formats : ${formats.join(', ')}`);
|
|
58
|
+
log(` Output dir : ${outDir}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 4. Resolve renderer (dw-kit-render package).
|
|
62
|
+
const rendererResolved = strategy === 'markdown-only' ? null : await tryResolveRenderer(projectDir);
|
|
63
|
+
const finalStrategy = strategy === 'markdown-only'
|
|
64
|
+
? 'markdown-only'
|
|
65
|
+
: (rendererResolved ? 'plugin' : (strategy === 'plugin' ? 'plugin-missing' : 'fallback-markdown'));
|
|
66
|
+
|
|
67
|
+
// Plugin strategy was requested but package is missing — fail loudly.
|
|
68
|
+
if (strategy === 'plugin' && !rendererResolved) {
|
|
69
|
+
err(`Strategy 'plugin' requested but '${RENDER_PACKAGE}' is not installed.`);
|
|
70
|
+
log(INSTALL_HINT);
|
|
71
|
+
logEvent({ event: 'review_render', action: 'fail', fallback_reason: 'no-renderer', strategy, formats }, projectDir);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let artifacts = { svg: [], png: [] };
|
|
76
|
+
let renderErrors = [];
|
|
77
|
+
|
|
78
|
+
if (rendererResolved) {
|
|
79
|
+
try {
|
|
80
|
+
if (!opts.quiet) info('Rendering with dw-kit-render');
|
|
81
|
+
const result = await rendererResolved.render({
|
|
82
|
+
manifest,
|
|
83
|
+
outDir,
|
|
84
|
+
formats,
|
|
85
|
+
theme: rendererCfg.theme,
|
|
86
|
+
font: rendererCfg.font,
|
|
87
|
+
});
|
|
88
|
+
artifacts = {
|
|
89
|
+
svg: Array.isArray(result?.svgPaths) ? result.svgPaths : [],
|
|
90
|
+
png: Array.isArray(result?.pngPaths) ? result.pngPaths : [],
|
|
91
|
+
};
|
|
92
|
+
} catch (e) {
|
|
93
|
+
renderErrors.push(e.message || String(e));
|
|
94
|
+
warn(`Renderer error: ${e.message || e}`);
|
|
95
|
+
warn('Falling back to markdown-only summary.');
|
|
96
|
+
}
|
|
97
|
+
} else if (strategy !== 'markdown-only') {
|
|
98
|
+
if (!opts.quiet) {
|
|
99
|
+
warn(`'${RENDER_PACKAGE}' not found — emitting markdown summary only.`);
|
|
100
|
+
log(INSTALL_HINT);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 5. Write summary.md (always — works without renderer too).
|
|
105
|
+
const summaryPath = join(outDir, 'summary.md');
|
|
106
|
+
writeFileSync(summaryPath, buildSummaryMarkdown(manifest, artifacts, { outDir, projectDir }), 'utf-8');
|
|
107
|
+
|
|
108
|
+
const durationMs = Math.round(performance.now() - startedAt);
|
|
109
|
+
|
|
110
|
+
if (!opts.quiet) {
|
|
111
|
+
console.log();
|
|
112
|
+
info('Artifacts');
|
|
113
|
+
log(` Summary : ${summaryPath}`);
|
|
114
|
+
if (artifacts.svg.length) log(` SVG : ${artifacts.svg.length} file(s)`);
|
|
115
|
+
if (artifacts.png.length) log(` PNG : ${artifacts.png.length} file(s)`);
|
|
116
|
+
if (!artifacts.svg.length && !artifacts.png.length) log(` ${chalk.dim('(markdown only — install dw-kit-render for images)')}`);
|
|
117
|
+
console.log();
|
|
118
|
+
if (renderErrors.length) {
|
|
119
|
+
warn(`${renderErrors.length} render error(s) — see above`);
|
|
120
|
+
} else {
|
|
121
|
+
ok(`Done in ${durationMs}ms`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
logEvent({
|
|
126
|
+
event: 'review_render',
|
|
127
|
+
action: renderErrors.length ? 'partial' : 'success',
|
|
128
|
+
strategy: finalStrategy,
|
|
129
|
+
formats,
|
|
130
|
+
findings: manifest.findings.length,
|
|
131
|
+
duration_ms: durationMs,
|
|
132
|
+
fallback_reason: rendererResolved ? null : (strategy === 'markdown-only' ? 'config-markdown-only' : 'no-renderer'),
|
|
133
|
+
}, projectDir);
|
|
134
|
+
|
|
135
|
+
process.exit(0);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function parseFormats(value) {
|
|
139
|
+
if (!value) return null;
|
|
140
|
+
if (value === 'both') return ['svg', 'png'];
|
|
141
|
+
if (value === 'svg' || value === 'png') return [value];
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function resolveOutputDir(configDir, manifest, projectDir) {
|
|
146
|
+
const baseDir = isAbsolute(configDir) ? configDir : join(projectDir, configDir);
|
|
147
|
+
const slug = manifest.scope_slug || manifest.scope || 'review';
|
|
148
|
+
return join(baseDir, slug);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function tryResolveRenderer(projectDir) {
|
|
152
|
+
// Test-only escape hatch: tests use this to force the markdown fallback even
|
|
153
|
+
// when a local renderer is dev-linked from a sibling packages/ dir.
|
|
154
|
+
if (process.env.DW_REVIEW_NO_RENDERER === '1') return null;
|
|
155
|
+
|
|
156
|
+
// Resolution order: project node_modules → CLI's own node_modules (global -g case).
|
|
157
|
+
// We use createRequire to get a *resolution* path, then dynamic-import that URL
|
|
158
|
+
// so the renderer can be ESM-only (Phase 1 ships ESM only).
|
|
159
|
+
for (const anchor of [join(projectDir, 'package.json'), import.meta.url]) {
|
|
160
|
+
try {
|
|
161
|
+
const req = createRequire(anchor);
|
|
162
|
+
const entry = req.resolve(RENDER_PACKAGE);
|
|
163
|
+
const mod = await import(pathToFileURL(entry).href);
|
|
164
|
+
const render = mod?.render || mod?.default?.render;
|
|
165
|
+
if (typeof render !== 'function') {
|
|
166
|
+
throw new Error("dw-kit-render module did not export a 'render' function");
|
|
167
|
+
}
|
|
168
|
+
return { render };
|
|
169
|
+
} catch {
|
|
170
|
+
// try next anchor
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function buildSummaryMarkdown(manifest, artifacts, { outDir, projectDir }) {
|
|
177
|
+
const lines = [];
|
|
178
|
+
const counts = countBySeverity(manifest.findings);
|
|
179
|
+
const slug = manifest.scope_slug || manifest.scope;
|
|
180
|
+
|
|
181
|
+
lines.push(`# Review summary — ${manifest.scope}`);
|
|
182
|
+
lines.push('');
|
|
183
|
+
lines.push(`- Generated: ${manifest.generated_at}`);
|
|
184
|
+
if (manifest.review_meta?.diff_base) lines.push(`- Diff base: ${manifest.review_meta.diff_base}`);
|
|
185
|
+
if (manifest.review_meta?.files_reviewed != null) lines.push(`- Files reviewed: ${manifest.review_meta.files_reviewed}`);
|
|
186
|
+
if (manifest.task_id) lines.push(`- Task: \`${manifest.task_id}\``);
|
|
187
|
+
lines.push('');
|
|
188
|
+
lines.push(`**Severity counts:** critical=${counts.critical}, warning=${counts.warning}, suggestion=${counts.suggestion}`);
|
|
189
|
+
lines.push('');
|
|
190
|
+
|
|
191
|
+
if (!manifest.findings.length) {
|
|
192
|
+
lines.push('No findings.');
|
|
193
|
+
lines.push('');
|
|
194
|
+
return lines.join('\n');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
lines.push('## Findings');
|
|
198
|
+
lines.push('');
|
|
199
|
+
for (const f of manifest.findings) {
|
|
200
|
+
const loc = formatLocation(f.location);
|
|
201
|
+
lines.push(`### [${f.severity.toUpperCase()}] ${f.title}`);
|
|
202
|
+
lines.push('');
|
|
203
|
+
lines.push(`- File: \`${loc}\``);
|
|
204
|
+
if (f.rule_ref) lines.push(`- Rule: ${f.rule_ref}`);
|
|
205
|
+
|
|
206
|
+
const svg = artifacts.svg.find((p) => p.endsWith(`finding-${f.id}.svg`));
|
|
207
|
+
const png = artifacts.png.find((p) => p.endsWith(`finding-${f.id}.png`));
|
|
208
|
+
if (svg) lines.push(`- SVG: \`${relativeTo(svg, outDir)}\``);
|
|
209
|
+
if (png) lines.push(`- PNG: \`${relativeTo(png, outDir)}\``);
|
|
210
|
+
lines.push('');
|
|
211
|
+
lines.push(f.body);
|
|
212
|
+
lines.push('');
|
|
213
|
+
if (f.code_snippet) {
|
|
214
|
+
const lang = f.language || '';
|
|
215
|
+
lines.push('```' + lang);
|
|
216
|
+
lines.push(f.code_snippet);
|
|
217
|
+
lines.push('```');
|
|
218
|
+
lines.push('');
|
|
219
|
+
}
|
|
220
|
+
if (f.fix) {
|
|
221
|
+
lines.push(`**Fix:** ${f.fix}`);
|
|
222
|
+
lines.push('');
|
|
223
|
+
}
|
|
224
|
+
lines.push('---');
|
|
225
|
+
lines.push('');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
lines.push(`*Manifest: \`${relativeTo(join(outDir, 'manifest.json'), projectDir)}\`*`);
|
|
229
|
+
lines.push('');
|
|
230
|
+
return lines.join('\n');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function countBySeverity(findings) {
|
|
234
|
+
const out = { critical: 0, warning: 0, suggestion: 0 };
|
|
235
|
+
for (const f of findings) {
|
|
236
|
+
if (out[f.severity] != null) out[f.severity]++;
|
|
237
|
+
}
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function formatLocation(loc) {
|
|
242
|
+
if (!loc) return '?';
|
|
243
|
+
if (loc.line_start && loc.line_end && loc.line_start !== loc.line_end) {
|
|
244
|
+
return `${loc.file}:${loc.line_start}-${loc.line_end}`;
|
|
245
|
+
}
|
|
246
|
+
if (loc.line_start) return `${loc.file}:${loc.line_start}`;
|
|
247
|
+
return loc.file;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function relativeTo(target, base) {
|
|
251
|
+
const t = target.replace(/\\/g, '/');
|
|
252
|
+
const b = base.replace(/\\/g, '/').replace(/\/$/, '');
|
|
253
|
+
if (t.startsWith(b + '/')) return t.slice(b.length + 1);
|
|
254
|
+
return t;
|
|
255
|
+
}
|
package/src/lib/config.mjs
CHANGED
|
@@ -1,104 +1,120 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
2
|
-
import yaml from 'js-yaml';
|
|
3
|
-
|
|
4
|
-
export function loadConfig(configPath) {
|
|
5
|
-
if (!existsSync(configPath)) return null;
|
|
6
|
-
const content = readFileSync(configPath, 'utf-8');
|
|
7
|
-
return yaml.load(content);
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Load config với local override (v1.2+).
|
|
12
|
-
* dw.config.yml — shared, committed
|
|
13
|
-
* dw.config.local.yml — machine-specific, gitignored
|
|
14
|
-
* Local values win over base values (shallow merge per top-level key).
|
|
15
|
-
*/
|
|
16
|
-
export function loadConfigWithLocal(configDir) {
|
|
17
|
-
const basePath = `${configDir}/dw.config.yml`;
|
|
18
|
-
const localPath = `${configDir}/dw.config.local.yml`;
|
|
19
|
-
|
|
20
|
-
const base = loadConfig(basePath);
|
|
21
|
-
if (!base) return null;
|
|
22
|
-
|
|
23
|
-
if (!existsSync(localPath)) return base;
|
|
24
|
-
|
|
25
|
-
const local = loadConfig(localPath);
|
|
26
|
-
if (!local) return base;
|
|
27
|
-
|
|
28
|
-
const merged = { ...base };
|
|
29
|
-
for (const key of Object.keys(local)) {
|
|
30
|
-
if (typeof local[key] === 'object' && !Array.isArray(local[key]) && local[key] !== null) {
|
|
31
|
-
merged[key] = { ...(merged[key] || {}), ...local[key] };
|
|
32
|
-
} else {
|
|
33
|
-
merged[key] = local[key];
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
return merged;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export function writeConfig(configPath, data) {
|
|
40
|
-
const content = yaml.dump(data, {
|
|
41
|
-
indent: 2,
|
|
42
|
-
lineWidth: -1,
|
|
43
|
-
quotingType: '"',
|
|
44
|
-
forceQuotes: false,
|
|
45
|
-
noRefs: true,
|
|
46
|
-
});
|
|
47
|
-
writeFileSync(configPath, content, 'utf-8');
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export function loadSchema(schemaPath) {
|
|
51
|
-
if (!existsSync(schemaPath)) return null;
|
|
52
|
-
return JSON.parse(readFileSync(schemaPath, 'utf-8'));
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export function buildConfig({ projectName, language, depth, roles }) {
|
|
56
|
-
const today = new Date().toISOString().split('T')[0];
|
|
57
|
-
return {
|
|
58
|
-
project: {
|
|
59
|
-
name: projectName,
|
|
60
|
-
language: language,
|
|
61
|
-
},
|
|
62
|
-
workflow: {
|
|
63
|
-
default_depth: depth,
|
|
64
|
-
},
|
|
65
|
-
team: {
|
|
66
|
-
roles: roles,
|
|
67
|
-
},
|
|
68
|
-
quality: {
|
|
69
|
-
test_command: '',
|
|
70
|
-
lint_command: '',
|
|
71
|
-
block_on_fail: false,
|
|
72
|
-
},
|
|
73
|
-
tracking: {
|
|
74
|
-
estimation: depth !== 'quick',
|
|
75
|
-
log_work: depth === 'thorough',
|
|
76
|
-
estimation_unit: 'hours',
|
|
77
|
-
},
|
|
78
|
-
paths: {
|
|
79
|
-
tasks: '.dw/tasks',
|
|
80
|
-
docs: '.dw/docs',
|
|
81
|
-
},
|
|
82
|
-
claude: {
|
|
83
|
-
models: { plan: '', execute: '', review: '' },
|
|
84
|
-
structured_output: depth !== 'quick',
|
|
85
|
-
worktree_execution: false,
|
|
86
|
-
mcp: [],
|
|
87
|
-
},
|
|
88
|
-
_toolkit: {
|
|
89
|
-
core_version: '1.2',
|
|
90
|
-
platform_version: '1.0',
|
|
91
|
-
capability_version: '1.2',
|
|
92
|
-
installed: today,
|
|
93
|
-
last_upgrade: today,
|
|
94
|
-
},
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export function getToolkitVersions(config) {
|
|
99
|
-
return {
|
|
100
|
-
core: config?._toolkit?.core_version || 'unknown',
|
|
101
|
-
platform: config?._toolkit?.platform_version || 'unknown',
|
|
102
|
-
capability: config?._toolkit?.capability_version || 'unknown',
|
|
103
|
-
};
|
|
104
|
-
}
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import yaml from 'js-yaml';
|
|
3
|
+
|
|
4
|
+
export function loadConfig(configPath) {
|
|
5
|
+
if (!existsSync(configPath)) return null;
|
|
6
|
+
const content = readFileSync(configPath, 'utf-8');
|
|
7
|
+
return yaml.load(content);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Load config với local override (v1.2+).
|
|
12
|
+
* dw.config.yml — shared, committed
|
|
13
|
+
* dw.config.local.yml — machine-specific, gitignored
|
|
14
|
+
* Local values win over base values (shallow merge per top-level key).
|
|
15
|
+
*/
|
|
16
|
+
export function loadConfigWithLocal(configDir) {
|
|
17
|
+
const basePath = `${configDir}/dw.config.yml`;
|
|
18
|
+
const localPath = `${configDir}/dw.config.local.yml`;
|
|
19
|
+
|
|
20
|
+
const base = loadConfig(basePath);
|
|
21
|
+
if (!base) return null;
|
|
22
|
+
|
|
23
|
+
if (!existsSync(localPath)) return base;
|
|
24
|
+
|
|
25
|
+
const local = loadConfig(localPath);
|
|
26
|
+
if (!local) return base;
|
|
27
|
+
|
|
28
|
+
const merged = { ...base };
|
|
29
|
+
for (const key of Object.keys(local)) {
|
|
30
|
+
if (typeof local[key] === 'object' && !Array.isArray(local[key]) && local[key] !== null) {
|
|
31
|
+
merged[key] = { ...(merged[key] || {}), ...local[key] };
|
|
32
|
+
} else {
|
|
33
|
+
merged[key] = local[key];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return merged;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function writeConfig(configPath, data) {
|
|
40
|
+
const content = yaml.dump(data, {
|
|
41
|
+
indent: 2,
|
|
42
|
+
lineWidth: -1,
|
|
43
|
+
quotingType: '"',
|
|
44
|
+
forceQuotes: false,
|
|
45
|
+
noRefs: true,
|
|
46
|
+
});
|
|
47
|
+
writeFileSync(configPath, content, 'utf-8');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function loadSchema(schemaPath) {
|
|
51
|
+
if (!existsSync(schemaPath)) return null;
|
|
52
|
+
return JSON.parse(readFileSync(schemaPath, 'utf-8'));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function buildConfig({ projectName, language, depth, roles }) {
|
|
56
|
+
const today = new Date().toISOString().split('T')[0];
|
|
57
|
+
return {
|
|
58
|
+
project: {
|
|
59
|
+
name: projectName,
|
|
60
|
+
language: language,
|
|
61
|
+
},
|
|
62
|
+
workflow: {
|
|
63
|
+
default_depth: depth,
|
|
64
|
+
},
|
|
65
|
+
team: {
|
|
66
|
+
roles: roles,
|
|
67
|
+
},
|
|
68
|
+
quality: {
|
|
69
|
+
test_command: '',
|
|
70
|
+
lint_command: '',
|
|
71
|
+
block_on_fail: false,
|
|
72
|
+
},
|
|
73
|
+
tracking: {
|
|
74
|
+
estimation: depth !== 'quick',
|
|
75
|
+
log_work: depth === 'thorough',
|
|
76
|
+
estimation_unit: 'hours',
|
|
77
|
+
},
|
|
78
|
+
paths: {
|
|
79
|
+
tasks: '.dw/tasks',
|
|
80
|
+
docs: '.dw/docs',
|
|
81
|
+
},
|
|
82
|
+
claude: {
|
|
83
|
+
models: { plan: '', execute: '', review: '' },
|
|
84
|
+
structured_output: depth !== 'quick',
|
|
85
|
+
worktree_execution: false,
|
|
86
|
+
mcp: [],
|
|
87
|
+
},
|
|
88
|
+
_toolkit: {
|
|
89
|
+
core_version: '1.2',
|
|
90
|
+
platform_version: '1.0',
|
|
91
|
+
capability_version: '1.2',
|
|
92
|
+
installed: today,
|
|
93
|
+
last_upgrade: today,
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function getToolkitVersions(config) {
|
|
99
|
+
return {
|
|
100
|
+
core: config?._toolkit?.core_version || 'unknown',
|
|
101
|
+
platform: config?._toolkit?.platform_version || 'unknown',
|
|
102
|
+
capability: config?._toolkit?.capability_version || 'unknown',
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Renderer config for /dw:review --visual + `dw review render` (ADR-0007).
|
|
108
|
+
* Falls back to defaults when keys are absent so existing projects work
|
|
109
|
+
* without re-running `dw init`.
|
|
110
|
+
*/
|
|
111
|
+
export function getReviewRendererConfig(config) {
|
|
112
|
+
const r = config?.claude?.review?.renderer || {};
|
|
113
|
+
return {
|
|
114
|
+
strategy: r.strategy || 'auto',
|
|
115
|
+
theme: r.theme || 'github-dark',
|
|
116
|
+
font: r.font || 'JetBrains Mono',
|
|
117
|
+
formats: Array.isArray(r.formats) && r.formats.length ? r.formats : ['svg', 'png'],
|
|
118
|
+
output_dir: r.output_dir || '.dw/reviews',
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "https://github.com/dv-workflow/dv-workflow/schemas/review-manifest.json",
|
|
4
|
+
"title": "dw-kit Review Render Manifest",
|
|
5
|
+
"description": "Structured findings manifest produced by /dw:review --visual and consumed by `dw review render` + dw-kit-render. Versioned public API surface — see ADR-0007.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": ["schema_version", "scope", "generated_at", "findings"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"schema_version": {
|
|
11
|
+
"type": "integer",
|
|
12
|
+
"const": 1,
|
|
13
|
+
"description": "Manifest schema version. Renderer rejects mismatched versions with clear error."
|
|
14
|
+
},
|
|
15
|
+
"scope": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"minLength": 1,
|
|
18
|
+
"maxLength": 200,
|
|
19
|
+
"description": "Review scope identifier (branch slug, task slug, or arbitrary label). NOT used directly as path — caller must sanitize via scope-slug util."
|
|
20
|
+
},
|
|
21
|
+
"scope_slug": {
|
|
22
|
+
"type": "string",
|
|
23
|
+
"minLength": 1,
|
|
24
|
+
"maxLength": 80,
|
|
25
|
+
"pattern": "^[a-zA-Z0-9._-]+$",
|
|
26
|
+
"description": "Sanitized scope used as directory name under .dw/reviews/. Filesystem-safe on Windows + POSIX."
|
|
27
|
+
},
|
|
28
|
+
"generated_at": {
|
|
29
|
+
"type": "string",
|
|
30
|
+
"format": "date-time",
|
|
31
|
+
"description": "ISO-8601 timestamp when manifest was produced."
|
|
32
|
+
},
|
|
33
|
+
"task_id": {
|
|
34
|
+
"type": "string",
|
|
35
|
+
"description": "Optional link to .dw/tasks/{task_id}/ — enables /dw:execute to load findings as fix targets."
|
|
36
|
+
},
|
|
37
|
+
"review_meta": {
|
|
38
|
+
"type": "object",
|
|
39
|
+
"additionalProperties": false,
|
|
40
|
+
"properties": {
|
|
41
|
+
"reviewer": { "type": "string", "description": "Skill or agent name that produced this manifest (e.g. 'dw-review')." },
|
|
42
|
+
"depth": { "type": "string", "enum": ["quick", "standard", "thorough"] },
|
|
43
|
+
"diff_base": { "type": "string", "description": "Git ref the review was computed against (e.g. 'main', 'origin/dev')." },
|
|
44
|
+
"files_reviewed": { "type": "integer", "minimum": 0 }
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"findings": {
|
|
48
|
+
"type": "array",
|
|
49
|
+
"minItems": 0,
|
|
50
|
+
"items": { "$ref": "#/definitions/finding" }
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"definitions": {
|
|
54
|
+
"finding": {
|
|
55
|
+
"type": "object",
|
|
56
|
+
"additionalProperties": false,
|
|
57
|
+
"required": ["id", "severity", "title", "location", "body"],
|
|
58
|
+
"properties": {
|
|
59
|
+
"id": {
|
|
60
|
+
"type": "string",
|
|
61
|
+
"pattern": "^[a-zA-Z0-9._-]+$",
|
|
62
|
+
"minLength": 1,
|
|
63
|
+
"maxLength": 64,
|
|
64
|
+
"description": "Unique finding ID within the manifest. Used as artifact filename: finding-{id}.svg / .png."
|
|
65
|
+
},
|
|
66
|
+
"severity": {
|
|
67
|
+
"type": "string",
|
|
68
|
+
"enum": ["critical", "warning", "suggestion"],
|
|
69
|
+
"description": "Drives banner color in rendered artifact."
|
|
70
|
+
},
|
|
71
|
+
"title": {
|
|
72
|
+
"type": "string",
|
|
73
|
+
"minLength": 1,
|
|
74
|
+
"maxLength": 200,
|
|
75
|
+
"description": "One-line summary shown as the card heading."
|
|
76
|
+
},
|
|
77
|
+
"location": {
|
|
78
|
+
"type": "object",
|
|
79
|
+
"additionalProperties": false,
|
|
80
|
+
"required": ["file"],
|
|
81
|
+
"properties": {
|
|
82
|
+
"file": {
|
|
83
|
+
"type": "string",
|
|
84
|
+
"minLength": 1,
|
|
85
|
+
"description": "Repo-relative path. Forward slashes regardless of OS."
|
|
86
|
+
},
|
|
87
|
+
"line_start": {
|
|
88
|
+
"type": "integer",
|
|
89
|
+
"minimum": 1,
|
|
90
|
+
"description": "First relevant line (1-indexed)."
|
|
91
|
+
},
|
|
92
|
+
"line_end": {
|
|
93
|
+
"type": "integer",
|
|
94
|
+
"minimum": 1,
|
|
95
|
+
"description": "Last relevant line (1-indexed, inclusive)."
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
"rule_ref": {
|
|
100
|
+
"type": "string",
|
|
101
|
+
"maxLength": 200,
|
|
102
|
+
"description": "Optional rule/standard reference shown in card subhead."
|
|
103
|
+
},
|
|
104
|
+
"body": {
|
|
105
|
+
"type": "string",
|
|
106
|
+
"minLength": 1,
|
|
107
|
+
"maxLength": 4000,
|
|
108
|
+
"description": "Finding explanation (markdown allowed in rendered output, plain text safe fallback)."
|
|
109
|
+
},
|
|
110
|
+
"fix": {
|
|
111
|
+
"type": "string",
|
|
112
|
+
"maxLength": 4000,
|
|
113
|
+
"description": "Optional concrete fix suggestion shown as action banner."
|
|
114
|
+
},
|
|
115
|
+
"code_snippet": {
|
|
116
|
+
"type": "string",
|
|
117
|
+
"maxLength": 8000,
|
|
118
|
+
"description": "Raw code lines from `location` range. Pulled by the LLM during manifest generation; renderer does not re-read source files. Cap at ~50 lines around finding."
|
|
119
|
+
},
|
|
120
|
+
"language": {
|
|
121
|
+
"type": "string",
|
|
122
|
+
"maxLength": 32,
|
|
123
|
+
"description": "Language identifier for syntax highlighting (e.g. javascript, python, go). Defaults to plain text in renderer if absent or unknown."
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
"allOf": [
|
|
127
|
+
{
|
|
128
|
+
"if": {
|
|
129
|
+
"properties": {
|
|
130
|
+
"location": {
|
|
131
|
+
"required": ["line_start", "line_end"]
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
"then": {
|
|
136
|
+
"properties": {
|
|
137
|
+
"location": {
|
|
138
|
+
"properties": {
|
|
139
|
+
"line_end": { "type": "integer" },
|
|
140
|
+
"line_start": { "type": "integer" }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
]
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
import Ajv from 'ajv';
|
|
5
|
+
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const SCHEMA_PATH = resolve(__dirname, 'manifest-schema.json');
|
|
8
|
+
|
|
9
|
+
let _schema = null;
|
|
10
|
+
let _ajv = null;
|
|
11
|
+
let _validate = null;
|
|
12
|
+
|
|
13
|
+
export const CURRENT_SCHEMA_VERSION = 1;
|
|
14
|
+
|
|
15
|
+
const ISO_DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})?$/;
|
|
16
|
+
|
|
17
|
+
function getSchema() {
|
|
18
|
+
if (!_schema) {
|
|
19
|
+
_schema = JSON.parse(readFileSync(SCHEMA_PATH, 'utf-8'));
|
|
20
|
+
}
|
|
21
|
+
return _schema;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getValidator() {
|
|
25
|
+
if (_validate) return _validate;
|
|
26
|
+
_ajv = new Ajv({ allErrors: true, strict: false });
|
|
27
|
+
_ajv.addFormat('date-time', ISO_DATE_TIME);
|
|
28
|
+
_validate = _ajv.compile(getSchema());
|
|
29
|
+
return _validate;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Validate a manifest object against the schema.
|
|
34
|
+
*
|
|
35
|
+
* @param {object} manifest - parsed manifest JSON
|
|
36
|
+
* @returns {{ok: boolean, errors: Array<{path: string, message: string}>}}
|
|
37
|
+
*/
|
|
38
|
+
export function validateManifest(manifest) {
|
|
39
|
+
if (manifest == null || typeof manifest !== 'object') {
|
|
40
|
+
return { ok: false, errors: [{ path: '', message: 'manifest must be an object' }] };
|
|
41
|
+
}
|
|
42
|
+
if (Number.isInteger(manifest.schema_version) && manifest.schema_version !== CURRENT_SCHEMA_VERSION) {
|
|
43
|
+
return {
|
|
44
|
+
ok: false,
|
|
45
|
+
errors: [{
|
|
46
|
+
path: '/schema_version',
|
|
47
|
+
message: `unsupported schema_version=${manifest.schema_version}; renderer supports ${CURRENT_SCHEMA_VERSION}. Upgrade dw-kit or regenerate manifest.`,
|
|
48
|
+
}],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
const validate = getValidator();
|
|
52
|
+
const valid = validate(manifest);
|
|
53
|
+
if (valid) return { ok: true, errors: [] };
|
|
54
|
+
const errors = (validate.errors || []).map((e) => ({
|
|
55
|
+
path: e.instancePath || '/',
|
|
56
|
+
message: `${e.message}${e.params && Object.keys(e.params).length ? ' (' + JSON.stringify(e.params) + ')' : ''}`,
|
|
57
|
+
}));
|
|
58
|
+
return { ok: false, errors };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Parse + validate from JSON string.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} jsonText
|
|
65
|
+
* @returns {{ok: boolean, manifest?: object, errors: Array}}
|
|
66
|
+
*/
|
|
67
|
+
export function parseManifest(jsonText) {
|
|
68
|
+
let manifest;
|
|
69
|
+
try {
|
|
70
|
+
manifest = JSON.parse(jsonText);
|
|
71
|
+
} catch (e) {
|
|
72
|
+
return { ok: false, errors: [{ path: '', message: `invalid JSON: ${e.message}` }] };
|
|
73
|
+
}
|
|
74
|
+
const result = validateManifest(manifest);
|
|
75
|
+
if (!result.ok) return { ok: false, errors: result.errors };
|
|
76
|
+
return { ok: true, manifest, errors: [] };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Read + validate manifest file from disk.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} manifestPath
|
|
83
|
+
* @returns {{ok: boolean, manifest?: object, errors: Array}}
|
|
84
|
+
*/
|
|
85
|
+
export function readManifest(manifestPath) {
|
|
86
|
+
let text;
|
|
87
|
+
try {
|
|
88
|
+
text = readFileSync(manifestPath, 'utf-8');
|
|
89
|
+
} catch (e) {
|
|
90
|
+
return { ok: false, errors: [{ path: '', message: `cannot read manifest: ${e.message}` }] };
|
|
91
|
+
}
|
|
92
|
+
return parseManifest(text);
|
|
93
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sanitize an arbitrary scope string (branch name, task name, free-form label)
|
|
3
|
+
* into a filesystem-safe directory slug usable on Windows + POSIX.
|
|
4
|
+
*
|
|
5
|
+
* Rules:
|
|
6
|
+
* - Strip filesystem-illegal chars on Windows (NTFS): / \ : * ? " < > |
|
|
7
|
+
* - Collapse whitespace + control chars to single dash
|
|
8
|
+
* - Collapse runs of dashes/underscores/dots
|
|
9
|
+
* - Trim leading/trailing dashes, underscores, dots
|
|
10
|
+
* - Forbid reserved Windows device names (CON, PRN, AUX, NUL, COM1-9, LPT1-9)
|
|
11
|
+
* by prefixing with `_` if matched
|
|
12
|
+
* - Cap length at maxLength (default 80)
|
|
13
|
+
* - Lowercase optional (default keeps case; renderer config can opt-in)
|
|
14
|
+
*
|
|
15
|
+
* Examples:
|
|
16
|
+
* "fix/sc-guard-v1.3.6" → "fix-sc-guard-v1.3.6"
|
|
17
|
+
* "feat: thêm tính năng" → "feat-thêm-tính-năng" (Unicode preserved)
|
|
18
|
+
* " multiple spaces " → "multiple-spaces"
|
|
19
|
+
* "CON" → "_CON"
|
|
20
|
+
* "../../etc/passwd" → "etc-passwd"
|
|
21
|
+
*
|
|
22
|
+
* @param {string} scope - raw scope string
|
|
23
|
+
* @param {{maxLength?: number, lowercase?: boolean}} [opts]
|
|
24
|
+
* @returns {string} sanitized slug
|
|
25
|
+
* @throws {Error} if input is empty after sanitization
|
|
26
|
+
*/
|
|
27
|
+
export function scopeSlug(scope, opts = {}) {
|
|
28
|
+
const { maxLength = 80, lowercase = false } = opts;
|
|
29
|
+
|
|
30
|
+
if (typeof scope !== 'string') {
|
|
31
|
+
throw new Error('scope must be a string');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let s = scope;
|
|
35
|
+
|
|
36
|
+
// Strip Windows-illegal chars + control chars (0x00-0x1F, 0x7F).
|
|
37
|
+
s = s.replace(/[\\/:*?"<>|\x00-\x1F\x7F]+/g, '-');
|
|
38
|
+
|
|
39
|
+
// Strip path traversal segments.
|
|
40
|
+
s = s.replace(/\.\.+/g, '-');
|
|
41
|
+
|
|
42
|
+
// Collapse whitespace runs to single dash.
|
|
43
|
+
s = s.replace(/\s+/g, '-');
|
|
44
|
+
|
|
45
|
+
// Collapse runs of separators (dash/underscore/dot) — keep one.
|
|
46
|
+
s = s.replace(/[-_.]{2,}/g, (m) => m[0]);
|
|
47
|
+
|
|
48
|
+
// Trim leading/trailing separators.
|
|
49
|
+
s = s.replace(/^[-_.]+|[-_.]+$/g, '');
|
|
50
|
+
|
|
51
|
+
if (lowercase) s = s.toLowerCase();
|
|
52
|
+
|
|
53
|
+
// Reserved Windows device names (case-insensitive).
|
|
54
|
+
if (/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i.test(s)) {
|
|
55
|
+
s = `_${s}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Cap length.
|
|
59
|
+
if (s.length > maxLength) {
|
|
60
|
+
s = s.slice(0, maxLength).replace(/[-_.]+$/g, '');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (s.length === 0) {
|
|
64
|
+
throw new Error('scope sanitization produced empty slug — provide a non-empty alphanumeric scope');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return s;
|
|
68
|
+
}
|