ccgx-workflow 2.3.1 → 2.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,266 @@
1
+ #!/usr/bin/env node
2
+ // CCG Spec Suggestion CLI — RFC-9 F2 follow-up.
3
+ //
4
+ // Standalone Node CJS reimplementation of src/utils/spec-suggestion.ts. The
5
+ // TS version lives in CCG's own dist and is great for the `ccgx-workflow`
6
+ // package consumers; but `/ccg:spec-impl` runs in the USER's project, which
7
+ // generally has no dependency on ccgx-workflow. The model can't `import` a
8
+ // helper that isn't installed.
9
+ //
10
+ // installer.ts copies this file to ~/.claude/.ccg/scripts/spec-suggestion.cjs
11
+ // (alongside check-plugins.cjs and friends). The spec-impl.md step calls:
12
+ //
13
+ // node ~/.claude/.ccg/scripts/spec-suggestion.cjs \
14
+ // --diff <path-to-diff> \
15
+ // --review <path-to-review-md> \
16
+ // --change-id <id>
17
+ //
18
+ // stdout = JSON array of candidates (same shape as TS SpecCandidate[]).
19
+ // exit 0 = ran (including empty result).
20
+ // exit 1 = bad args / read failure / internal error.
21
+ //
22
+ // Logic must stay in lockstep with src/utils/spec-suggestion.ts. The shared
23
+ // test cases in __tests__/specSuggestionCjs.test.ts pin both implementations
24
+ // against the same fixtures.
25
+
26
+ 'use strict';
27
+
28
+ const fs = require('node:fs');
29
+
30
+ // ─────────────────────────────────────────────────────────────────
31
+ // Constants (must mirror spec-suggestion.ts)
32
+ // ─────────────────────────────────────────────────────────────────
33
+ const MIN_RATIONALE_CHARS = 40;
34
+ const MAX_STATEMENT_CHARS = 200;
35
+
36
+ const FILLER_PHRASES = [
37
+ '写好的代码',
38
+ '注意安全',
39
+ '小心',
40
+ '保持代码整洁',
41
+ '遵循最佳实践',
42
+ '请仔细审查',
43
+ 'best practice',
44
+ 'be careful',
45
+ 'write good code',
46
+ 'clean code',
47
+ 'follow best practices',
48
+ ];
49
+
50
+ const SPECIFIC_REFERENCE_PATTERN = /(?:[a-zA-Z0-9_-]+\.[a-zA-Z]{1,8}\b|\w+\/\w+|\b[A-Z][a-zA-Z0-9]{2,}\b|`[^`]+`)/;
51
+
52
+ const CATEGORY_HINTS = [
53
+ { kw: /\b(api|endpoint|route|router|middleware|jwt|oauth|session|cookie|sql|schema|migration|database|orm|prisma|drizzle|knex|graphql|grpc|kafka|redis)\b/i, category: 'backend' },
54
+ { kw: /\b(component|hook|jsx|tsx|css|tailwind|store|reducer|redux|zustand|pinia|router\.(get|push)|route|svelte|vue|react)\b/i, category: 'frontend' },
55
+ { kw: /(?:认证|授权|加密|后端|接口|数据库|迁移)/, category: 'backend' },
56
+ { kw: /(?:前端|组件|页面|样式|交互|路由跳转)/, category: 'frontend' },
57
+ ];
58
+
59
+ // ─────────────────────────────────────────────────────────────────
60
+ // Pure helpers
61
+ // ─────────────────────────────────────────────────────────────────
62
+
63
+ function categorizeStatement(statement, rationale) {
64
+ const haystack = `${statement} ${rationale}`;
65
+ for (const hint of CATEGORY_HINTS) {
66
+ if (hint.kw.test(haystack)) return hint.category;
67
+ }
68
+ return 'cross-cutting';
69
+ }
70
+
71
+ function checkQuality(statement, rationale) {
72
+ if (!statement || statement.length < 10) return 'statement too short (< 10 chars)';
73
+ if (statement.length > MAX_STATEMENT_CHARS) return `statement too long (> ${MAX_STATEMENT_CHARS} chars) — break it down`;
74
+ if (rationale.length < MIN_RATIONALE_CHARS) return `rationale too thin (< ${MIN_RATIONALE_CHARS} chars) — explain *why*`;
75
+ const lower = `${statement} ${rationale}`.toLowerCase();
76
+ for (const filler of FILLER_PHRASES) {
77
+ if (lower.includes(filler.toLowerCase())) return `filler phrase detected: "${filler}"`;
78
+ }
79
+ if (!SPECIFIC_REFERENCE_PATTERN.test(`${statement} ${rationale}`)) {
80
+ return 'no specific file / API / command reference — add a concrete anchor';
81
+ }
82
+ return undefined;
83
+ }
84
+
85
+ function extractFromReview(reviewMd) {
86
+ const out = [];
87
+ const findingRe = /^[#\-*\s]*(?:Finding\s+)?[CW]-?\d+\b[^\n]*(?:Critical|Warning|critical|warning)[^\n]*$/gm;
88
+ let match;
89
+ while ((match = findingRe.exec(reviewMd)) !== null) {
90
+ const start = match.index;
91
+ const tail = reviewMd.slice(start, start + 1200);
92
+ const lines = tail.split('\n').slice(0, 12);
93
+ const header = lines[0].trim();
94
+ const body = lines.slice(1).join(' ').replace(/\s+/g, ' ').trim();
95
+ const sugMatch = body.match(/(?:建议[::]|Suggested(?:\s+fix)?[::]|应当?[::]|Must[::]|Should[::])\s*([^\.。;;]+)/i);
96
+ const statement = (sugMatch && sugMatch[1] ? sugMatch[1] : header).slice(0, MAX_STATEMENT_CHARS).trim();
97
+ const rationale = body.slice(0, 400).trim();
98
+ const category = categorizeStatement(statement, rationale);
99
+ out.push({
100
+ statement,
101
+ rationale,
102
+ source: { kind: 'review', excerpt: header },
103
+ category,
104
+ rejectionReason: checkQuality(statement, rationale),
105
+ });
106
+ }
107
+ return out;
108
+ }
109
+
110
+ function extractFromDiff(gitDiff) {
111
+ const out = [];
112
+ const lines = gitDiff.split('\n');
113
+ let currentFile = '';
114
+ const fileRe = /^\+\+\+ b\/(.+)$/;
115
+ const constraintRe = /^\+\s*(?:\/\/|#|--|\*)\s*(?:MUST|必须|invariant|约束|不变量|注意[::])\s*(.+)/i;
116
+ for (let i = 0; i < lines.length; i++) {
117
+ const line = lines[i];
118
+ const fm = line.match(fileRe);
119
+ if (fm) {
120
+ currentFile = fm[1];
121
+ continue;
122
+ }
123
+ const cm = line.match(constraintRe);
124
+ if (!cm) continue;
125
+ const statement = cm[1].trim().slice(0, MAX_STATEMENT_CHARS);
126
+ const context = lines.slice(Math.max(0, i - 3), Math.min(lines.length, i + 4))
127
+ .map(l => l.replace(/^[+\-]\s?/, ''))
128
+ .join('\n').slice(0, 400);
129
+ const rationale = `In ${currentFile}: ${context}`.slice(0, 400);
130
+ const category = categorizeStatement(statement, rationale);
131
+ out.push({
132
+ statement,
133
+ rationale,
134
+ source: { kind: 'diff', excerpt: `${currentFile} (${cm[0].slice(0, 80)}...)` },
135
+ category,
136
+ rejectionReason: checkQuality(statement, rationale),
137
+ });
138
+ }
139
+ return out;
140
+ }
141
+
142
+ function dedupeByStatement(candidates) {
143
+ const seen = new Set();
144
+ const out = [];
145
+ for (const c of candidates) {
146
+ const key = c.statement.toLowerCase().replace(/\s+/g, ' ').trim();
147
+ if (seen.has(key)) continue;
148
+ seen.add(key);
149
+ out.push(c);
150
+ }
151
+ return out;
152
+ }
153
+
154
+ function extractCandidates(input) {
155
+ const out = [];
156
+ if (input.reviewMd) out.push(...extractFromReview(input.reviewMd));
157
+ if (input.gitDiff) out.push(...extractFromDiff(input.gitDiff));
158
+ return dedupeByStatement(out);
159
+ }
160
+
161
+ // ─────────────────────────────────────────────────────────────────
162
+ // CLI
163
+ // ─────────────────────────────────────────────────────────────────
164
+
165
+ const MAX_INPUT_BYTES = 5 * 1024 * 1024; // 5MB cap per input file
166
+
167
+ function readBoundedFile(path) {
168
+ // Defensive: a 1GB diff file would otherwise blow process memory. Real diffs
169
+ // for a single change rarely exceed 200KB; 5MB is generous.
170
+ try {
171
+ const stat = fs.statSync(path);
172
+ if (!stat.isFile()) return null;
173
+ if (stat.size > MAX_INPUT_BYTES) {
174
+ process.stderr.write(`spec-suggestion: ${path} exceeds ${MAX_INPUT_BYTES} bytes (${stat.size}) — refusing\n`);
175
+ return null;
176
+ }
177
+ return fs.readFileSync(path, 'utf-8');
178
+ }
179
+ catch (err) {
180
+ process.stderr.write(`spec-suggestion: cannot read ${path}: ${err.message}\n`);
181
+ return null;
182
+ }
183
+ }
184
+
185
+ function parseArgs(argv) {
186
+ // Minimal flag parser — keep deps zero. Recognized:
187
+ // --diff <path> path to git diff file
188
+ // --review <path> path to REVIEW.md
189
+ // --change-id <id> optional change identifier (passed through, not used)
190
+ // --help print usage
191
+ const out = { diff: null, review: null, changeId: null, help: false };
192
+ for (let i = 2; i < argv.length; i++) {
193
+ const a = argv[i];
194
+ if (a === '--help' || a === '-h') {
195
+ out.help = true;
196
+ }
197
+ else if (a === '--diff' && i + 1 < argv.length) {
198
+ out.diff = argv[++i];
199
+ }
200
+ else if (a === '--review' && i + 1 < argv.length) {
201
+ out.review = argv[++i];
202
+ }
203
+ else if (a === '--change-id' && i + 1 < argv.length) {
204
+ out.changeId = argv[++i];
205
+ }
206
+ else {
207
+ process.stderr.write(`spec-suggestion: unknown arg: ${a}\n`);
208
+ out.help = true;
209
+ }
210
+ }
211
+ return out;
212
+ }
213
+
214
+ function printUsage() {
215
+ process.stderr.write([
216
+ 'Usage: node spec-suggestion.cjs --diff <path> --review <path> [--change-id <id>]',
217
+ '',
218
+ 'Extracts spec-evolution candidates from a git diff + REVIEW.md and prints',
219
+ 'a JSON array to stdout. Used by /ccg:spec-impl Step 10 (RFC-9).',
220
+ '',
221
+ 'At least one of --diff or --review must be provided.',
222
+ ].join('\n') + '\n');
223
+ }
224
+
225
+ function main(argv) {
226
+ const args = parseArgs(argv);
227
+ if (args.help || (!args.diff && !args.review)) {
228
+ printUsage();
229
+ return 1;
230
+ }
231
+
232
+ const gitDiff = args.diff ? readBoundedFile(args.diff) : '';
233
+ const reviewMd = args.review ? readBoundedFile(args.review) : '';
234
+
235
+ // If both paths were given but neither readable, fail loud. If only one was
236
+ // given and unreadable, also fail (the user expected it to be there).
237
+ if (args.diff && gitDiff === null) return 1;
238
+ if (args.review && reviewMd === null) return 1;
239
+
240
+ const candidates = extractCandidates({
241
+ gitDiff: gitDiff || undefined,
242
+ reviewMd: reviewMd || undefined,
243
+ changeId: args.changeId || undefined,
244
+ });
245
+
246
+ process.stdout.write(JSON.stringify(candidates, null, 2) + '\n');
247
+ return 0;
248
+ }
249
+
250
+ if (require.main === module) {
251
+ process.exit(main(process.argv));
252
+ }
253
+
254
+ module.exports = {
255
+ extractCandidates,
256
+ extractFromReview,
257
+ extractFromDiff,
258
+ categorizeStatement,
259
+ checkQuality,
260
+ dedupeByStatement,
261
+ parseArgs,
262
+ main,
263
+ MIN_RATIONALE_CHARS,
264
+ MAX_STATEMENT_CHARS,
265
+ MAX_INPUT_BYTES,
266
+ };