peaks-cli 1.0.17 → 1.0.18
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/bin/peaks.js +0 -0
- package/dist/src/cli/commands/request-commands.js +109 -3
- package/dist/src/cli/commands/scan-commands.d.ts +3 -0
- package/dist/src/cli/commands/scan-commands.js +194 -0
- package/dist/src/cli/commands/workspace-commands.d.ts +3 -0
- package/dist/src/cli/commands/workspace-commands.js +32 -0
- package/dist/src/cli/program.js +4 -0
- package/dist/src/services/artifacts/artifact-lint-service.d.ts +23 -0
- package/dist/src/services/artifacts/artifact-lint-service.js +80 -0
- package/dist/src/services/artifacts/artifact-prerequisites.d.ts +28 -0
- package/dist/src/services/artifacts/artifact-prerequisites.js +77 -0
- package/dist/src/services/artifacts/repair-cycle-service.d.ts +23 -0
- package/dist/src/services/artifacts/repair-cycle-service.js +52 -0
- package/dist/src/services/artifacts/request-artifact-service.d.ts +14 -0
- package/dist/src/services/artifacts/request-artifact-service.js +73 -21
- package/dist/src/services/scan/acceptance-coverage-service.d.ts +42 -0
- package/dist/src/services/scan/acceptance-coverage-service.js +135 -0
- package/dist/src/services/scan/archetype-service.d.ts +5 -0
- package/dist/src/services/scan/archetype-service.js +253 -0
- package/dist/src/services/scan/diff-scope-service.d.ts +40 -0
- package/dist/src/services/scan/diff-scope-service.js +198 -0
- package/dist/src/services/scan/existing-system-service.d.ts +7 -0
- package/dist/src/services/scan/existing-system-service.js +300 -0
- package/dist/src/services/scan/scan-types.d.ts +59 -0
- package/dist/src/services/scan/scan-types.js +1 -0
- package/dist/src/services/scan/type-sanity-service.d.ts +23 -0
- package/dist/src/services/scan/type-sanity-service.js +108 -0
- package/dist/src/services/workspace/workspace-service.d.ts +16 -0
- package/dist/src/services/workspace/workspace-service.js +66 -0
- package/dist/src/shared/version.d.ts +1 -1
- package/dist/src/shared/version.js +1 -1
- package/package.json +1 -1
- package/skills/peaks-qa/SKILL.md +42 -0
- package/skills/peaks-rd/SKILL.md +65 -2
- package/skills/peaks-solo/SKILL.md +275 -57
- package/skills/peaks-solo/references/existing-system-extraction.md +78 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
2
|
+
import { basename, join, relative } from 'node:path';
|
|
3
|
+
import { isDirectory, pathExists, readText } from '../../shared/fs.js';
|
|
4
|
+
import { scanArchetype } from './archetype-service.js';
|
|
5
|
+
const DEFAULT_MAX_TOKENS = 40;
|
|
6
|
+
const DEFAULT_SAMPLES = 5;
|
|
7
|
+
const COLOR_KEYWORDS = ['color', 'primary', 'success', 'warning', 'error', 'danger', 'info', 'bg', 'background', 'border', 'text'];
|
|
8
|
+
const SPACING_KEYWORDS = ['spacing', 'gap', 'padding', 'margin', 'size'];
|
|
9
|
+
const TYPO_KEYWORDS = ['font', 'text-size', 'line-height', 'letter-spacing', 'heading'];
|
|
10
|
+
const RADIUS_KEYWORDS = ['radius', 'rounded'];
|
|
11
|
+
const STYLE_DIRS = ['src/styles', 'src/style', 'styles', 'src/assets/styles', 'src/theme', 'theme'];
|
|
12
|
+
const COMPONENT_DIRS = ['src/components', 'src/Components', 'components'];
|
|
13
|
+
const SERVICE_DIRS = ['src/services', 'src/service', 'src/api', 'src/apis'];
|
|
14
|
+
const HOOK_DIRS = ['src/hooks', 'src/hook', 'src/composables'];
|
|
15
|
+
function classifyToken(name) {
|
|
16
|
+
const lower = name.toLowerCase();
|
|
17
|
+
if (RADIUS_KEYWORDS.some((kw) => lower.includes(kw)))
|
|
18
|
+
return 'radius';
|
|
19
|
+
if (TYPO_KEYWORDS.some((kw) => lower.includes(kw)))
|
|
20
|
+
return 'typography';
|
|
21
|
+
if (SPACING_KEYWORDS.some((kw) => lower.includes(kw)))
|
|
22
|
+
return 'spacing';
|
|
23
|
+
if (COLOR_KEYWORDS.some((kw) => lower.includes(kw)))
|
|
24
|
+
return 'color';
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
function parseLessOrSassVars(content, sourceRel) {
|
|
28
|
+
const tokens = [];
|
|
29
|
+
// Match `@var: value;` (Less) or `$var: value;` (Sass)
|
|
30
|
+
const regex = /^\s*[@$]([a-zA-Z][\w-]*)\s*:\s*([^;]+);/gm;
|
|
31
|
+
let match;
|
|
32
|
+
while ((match = regex.exec(content)) !== null) {
|
|
33
|
+
const [, rawName, rawValue] = match;
|
|
34
|
+
if (rawName === undefined || rawValue === undefined)
|
|
35
|
+
continue;
|
|
36
|
+
tokens.push({ name: rawName, value: rawValue.trim(), source: sourceRel });
|
|
37
|
+
}
|
|
38
|
+
return tokens;
|
|
39
|
+
}
|
|
40
|
+
function parseCssVars(content, sourceRel) {
|
|
41
|
+
const tokens = [];
|
|
42
|
+
const regex = /--([a-zA-Z][\w-]*)\s*:\s*([^;]+);/g;
|
|
43
|
+
let match;
|
|
44
|
+
while ((match = regex.exec(content)) !== null) {
|
|
45
|
+
const [, rawName, rawValue] = match;
|
|
46
|
+
if (rawName === undefined || rawValue === undefined)
|
|
47
|
+
continue;
|
|
48
|
+
tokens.push({ name: `--${rawName}`, value: rawValue.trim(), source: sourceRel });
|
|
49
|
+
}
|
|
50
|
+
return tokens;
|
|
51
|
+
}
|
|
52
|
+
async function walkStyleFiles(projectRoot) {
|
|
53
|
+
const found = [];
|
|
54
|
+
for (const candidate of STYLE_DIRS) {
|
|
55
|
+
const full = join(projectRoot, candidate);
|
|
56
|
+
if (!(await isDirectory(full)))
|
|
57
|
+
continue;
|
|
58
|
+
const queue = [full];
|
|
59
|
+
while (queue.length > 0) {
|
|
60
|
+
const current = queue.shift();
|
|
61
|
+
if (current === undefined)
|
|
62
|
+
break;
|
|
63
|
+
let entries;
|
|
64
|
+
try {
|
|
65
|
+
entries = await readdir(current, { withFileTypes: true });
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
for (const entry of entries) {
|
|
71
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules')
|
|
72
|
+
continue;
|
|
73
|
+
const entryPath = join(current, entry.name);
|
|
74
|
+
if (entry.isDirectory()) {
|
|
75
|
+
queue.push(entryPath);
|
|
76
|
+
}
|
|
77
|
+
else if (/\.(less|scss|sass|css)$/i.test(entry.name)) {
|
|
78
|
+
found.push(entryPath);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return found;
|
|
84
|
+
}
|
|
85
|
+
async function extractTailwindTokens(projectRoot) {
|
|
86
|
+
const candidates = ['tailwind.config.js', 'tailwind.config.ts', 'tailwind.config.cjs', 'tailwind.config.mjs'];
|
|
87
|
+
for (const candidate of candidates) {
|
|
88
|
+
const full = join(projectRoot, candidate);
|
|
89
|
+
if (await pathExists(full)) {
|
|
90
|
+
const content = await readText(full);
|
|
91
|
+
const tokens = [];
|
|
92
|
+
// Heuristic: extract keys under theme.extend.* via simple regex.
|
|
93
|
+
const colorBlock = /colors\s*:\s*\{([\s\S]*?)\}/.exec(content);
|
|
94
|
+
if (colorBlock?.[1] !== undefined) {
|
|
95
|
+
const colorRegex = /([a-zA-Z_][\w-]*)\s*:\s*['"`]([^'"`]+)['"`]/g;
|
|
96
|
+
let match;
|
|
97
|
+
while ((match = colorRegex.exec(colorBlock[1])) !== null) {
|
|
98
|
+
const [, name, value] = match;
|
|
99
|
+
if (name !== undefined && value !== undefined) {
|
|
100
|
+
tokens.push({ name, value, source: candidate });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return { tokens, source: { path: candidate, kind: 'tailwind-config' } };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return { tokens: [], source: null };
|
|
108
|
+
}
|
|
109
|
+
async function listFilesByMtime(dir, exts, max) {
|
|
110
|
+
if (!(await isDirectory(dir)))
|
|
111
|
+
return [];
|
|
112
|
+
const collected = [];
|
|
113
|
+
const queue = [dir];
|
|
114
|
+
while (queue.length > 0) {
|
|
115
|
+
const current = queue.shift();
|
|
116
|
+
if (current === undefined)
|
|
117
|
+
break;
|
|
118
|
+
let entries;
|
|
119
|
+
try {
|
|
120
|
+
entries = await readdir(current, { withFileTypes: true });
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
for (const entry of entries) {
|
|
126
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules')
|
|
127
|
+
continue;
|
|
128
|
+
const full = join(current, entry.name);
|
|
129
|
+
if (entry.isDirectory()) {
|
|
130
|
+
queue.push(full);
|
|
131
|
+
}
|
|
132
|
+
else if (exts.test(entry.name)) {
|
|
133
|
+
try {
|
|
134
|
+
const stats = await stat(full);
|
|
135
|
+
collected.push({ path: full, mtimeMs: stats.mtimeMs });
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
// skip unreadable
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
collected.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
144
|
+
return collected.slice(0, max);
|
|
145
|
+
}
|
|
146
|
+
function classifyComponentNaming(samples) {
|
|
147
|
+
if (samples.length === 0)
|
|
148
|
+
return 'unknown';
|
|
149
|
+
let pascal = 0;
|
|
150
|
+
let kebab = 0;
|
|
151
|
+
for (const sample of samples) {
|
|
152
|
+
const name = basename(sample.path).replace(/\.[^.]+$/, '');
|
|
153
|
+
if (/^[A-Z][a-zA-Z0-9]*$/.test(name))
|
|
154
|
+
pascal += 1;
|
|
155
|
+
else if (/^[a-z][a-z0-9-]*$/.test(name))
|
|
156
|
+
kebab += 1;
|
|
157
|
+
}
|
|
158
|
+
if (pascal > 0 && kebab === 0)
|
|
159
|
+
return 'PascalCase';
|
|
160
|
+
if (kebab > 0 && pascal === 0)
|
|
161
|
+
return 'kebab-case';
|
|
162
|
+
if (pascal > 0 || kebab > 0)
|
|
163
|
+
return 'mixed';
|
|
164
|
+
return 'unknown';
|
|
165
|
+
}
|
|
166
|
+
async function firstExistingDir(projectRoot, candidates) {
|
|
167
|
+
for (const candidate of candidates) {
|
|
168
|
+
if (await isDirectory(join(projectRoot, candidate))) {
|
|
169
|
+
return candidate;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
function dedupeTokens(tokens, max) {
|
|
175
|
+
const seen = new Set();
|
|
176
|
+
const out = [];
|
|
177
|
+
for (const token of tokens) {
|
|
178
|
+
const key = `${token.name}=${token.value}`;
|
|
179
|
+
if (seen.has(key))
|
|
180
|
+
continue;
|
|
181
|
+
seen.add(key);
|
|
182
|
+
out.push(token);
|
|
183
|
+
if (out.length >= max)
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
function findInconsistencies(tokens) {
|
|
189
|
+
const issues = [];
|
|
190
|
+
const byName = new Map();
|
|
191
|
+
for (const token of tokens) {
|
|
192
|
+
const set = byName.get(token.name) ?? new Set();
|
|
193
|
+
set.add(token.value);
|
|
194
|
+
byName.set(token.name, set);
|
|
195
|
+
}
|
|
196
|
+
for (const [name, values] of byName.entries()) {
|
|
197
|
+
if (values.size > 1) {
|
|
198
|
+
issues.push(`token "${name}" has ${values.size} different values across sources: ${[...values].join(' | ')}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return issues;
|
|
202
|
+
}
|
|
203
|
+
export async function scanExistingSystem(options) {
|
|
204
|
+
const { projectRoot } = options;
|
|
205
|
+
const maxTokens = options.maxTokens ?? DEFAULT_MAX_TOKENS;
|
|
206
|
+
const maxSamples = options.maxSamplesPerKind ?? DEFAULT_SAMPLES;
|
|
207
|
+
const archetypeReport = await scanArchetype({ projectRoot });
|
|
208
|
+
if (archetypeReport.archetype === 'greenfield' || archetypeReport.archetype === 'unknown') {
|
|
209
|
+
return {
|
|
210
|
+
archetype: archetypeReport.archetype,
|
|
211
|
+
scanned: false,
|
|
212
|
+
scanSkippedReason: `archetype=${archetypeReport.archetype} — extraction only runs on legacy projects`,
|
|
213
|
+
visualTokens: { colors: [], spacing: [], typography: [], radii: [], sources: [] },
|
|
214
|
+
conventions: { componentNaming: 'unknown', componentDir: null, serviceDir: null, hookDir: null, samples: [] },
|
|
215
|
+
inconsistencies: []
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
const sources = [];
|
|
219
|
+
const rawTokens = [];
|
|
220
|
+
const styleFiles = await walkStyleFiles(projectRoot);
|
|
221
|
+
for (const file of styleFiles) {
|
|
222
|
+
const content = await readText(file);
|
|
223
|
+
const rel = relative(projectRoot, file).split(/[\\/]/).join('/');
|
|
224
|
+
if (/\.less$/i.test(file)) {
|
|
225
|
+
const lessTokens = parseLessOrSassVars(content, rel);
|
|
226
|
+
if (lessTokens.length > 0) {
|
|
227
|
+
sources.push({ path: rel, kind: 'less-vars' });
|
|
228
|
+
rawTokens.push(...lessTokens);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
else if (/\.s[ac]ss$/i.test(file)) {
|
|
232
|
+
const sassTokens = parseLessOrSassVars(content, rel);
|
|
233
|
+
if (sassTokens.length > 0) {
|
|
234
|
+
sources.push({ path: rel, kind: 'sass-vars' });
|
|
235
|
+
rawTokens.push(...sassTokens);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const cssVars = parseCssVars(content, rel);
|
|
239
|
+
if (cssVars.length > 0) {
|
|
240
|
+
sources.push({ path: rel, kind: 'css-vars' });
|
|
241
|
+
rawTokens.push(...cssVars);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
const tailwind = await extractTailwindTokens(projectRoot);
|
|
245
|
+
if (tailwind.source !== null) {
|
|
246
|
+
sources.push(tailwind.source);
|
|
247
|
+
rawTokens.push(...tailwind.tokens);
|
|
248
|
+
}
|
|
249
|
+
const colors = [];
|
|
250
|
+
const spacing = [];
|
|
251
|
+
const typography = [];
|
|
252
|
+
const radii = [];
|
|
253
|
+
for (const token of rawTokens) {
|
|
254
|
+
const category = classifyToken(token.name);
|
|
255
|
+
if (category === 'color')
|
|
256
|
+
colors.push(token);
|
|
257
|
+
else if (category === 'spacing')
|
|
258
|
+
spacing.push(token);
|
|
259
|
+
else if (category === 'typography')
|
|
260
|
+
typography.push(token);
|
|
261
|
+
else if (category === 'radius')
|
|
262
|
+
radii.push(token);
|
|
263
|
+
}
|
|
264
|
+
const componentDir = await firstExistingDir(projectRoot, COMPONENT_DIRS);
|
|
265
|
+
const serviceDir = await firstExistingDir(projectRoot, SERVICE_DIRS);
|
|
266
|
+
const hookDir = await firstExistingDir(projectRoot, HOOK_DIRS);
|
|
267
|
+
const componentSamples = componentDir !== null
|
|
268
|
+
? await listFilesByMtime(join(projectRoot, componentDir), /\.(tsx|jsx|vue|svelte)$/i, maxSamples)
|
|
269
|
+
: [];
|
|
270
|
+
const serviceSamples = serviceDir !== null
|
|
271
|
+
? await listFilesByMtime(join(projectRoot, serviceDir), /\.(ts|js)$/i, maxSamples)
|
|
272
|
+
: [];
|
|
273
|
+
const hookSamples = hookDir !== null
|
|
274
|
+
? await listFilesByMtime(join(projectRoot, hookDir), /\.(ts|js)$/i, maxSamples)
|
|
275
|
+
: [];
|
|
276
|
+
const samples = [
|
|
277
|
+
...componentSamples.map((sample) => ({ path: relative(projectRoot, sample.path).split(/[\\/]/).join('/'), kind: 'component' })),
|
|
278
|
+
...serviceSamples.map((sample) => ({ path: relative(projectRoot, sample.path).split(/[\\/]/).join('/'), kind: 'service' })),
|
|
279
|
+
...hookSamples.map((sample) => ({ path: relative(projectRoot, sample.path).split(/[\\/]/).join('/'), kind: 'hook' }))
|
|
280
|
+
];
|
|
281
|
+
return {
|
|
282
|
+
archetype: archetypeReport.archetype,
|
|
283
|
+
scanned: true,
|
|
284
|
+
visualTokens: {
|
|
285
|
+
colors: dedupeTokens(colors, maxTokens),
|
|
286
|
+
spacing: dedupeTokens(spacing, maxTokens),
|
|
287
|
+
typography: dedupeTokens(typography, maxTokens),
|
|
288
|
+
radii: dedupeTokens(radii, maxTokens),
|
|
289
|
+
sources
|
|
290
|
+
},
|
|
291
|
+
conventions: {
|
|
292
|
+
componentNaming: classifyComponentNaming(componentSamples),
|
|
293
|
+
componentDir,
|
|
294
|
+
serviceDir,
|
|
295
|
+
hookDir,
|
|
296
|
+
samples
|
|
297
|
+
},
|
|
298
|
+
inconsistencies: findInconsistencies(rawTokens)
|
|
299
|
+
};
|
|
300
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export type ProjectArchetype = 'greenfield' | 'legacy-frontend' | 'legacy-fullstack' | 'frontend-monorepo' | 'unknown';
|
|
2
|
+
export type ArchetypeSignal = {
|
|
3
|
+
name: string;
|
|
4
|
+
matched: boolean;
|
|
5
|
+
detail?: string;
|
|
6
|
+
};
|
|
7
|
+
export type ArchetypeReport = {
|
|
8
|
+
archetype: ProjectArchetype;
|
|
9
|
+
confidence: 'high' | 'medium' | 'low';
|
|
10
|
+
frontendOnly: boolean;
|
|
11
|
+
frontendOnlyReason: string;
|
|
12
|
+
signals: ArchetypeSignal[];
|
|
13
|
+
detected: {
|
|
14
|
+
hasPackageJson: boolean;
|
|
15
|
+
hasBackendFramework: boolean;
|
|
16
|
+
backendFrameworks: string[];
|
|
17
|
+
hasSwaggerOrProto: boolean;
|
|
18
|
+
swaggerPaths: string[];
|
|
19
|
+
hasMonorepoConfig: boolean;
|
|
20
|
+
monorepoConfigs: string[];
|
|
21
|
+
hasNextApiRoutes: boolean;
|
|
22
|
+
srcFileCount: number;
|
|
23
|
+
backendDirsPresent: string[];
|
|
24
|
+
lockfileAgeDays: number | null;
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
export type VisualTokenSource = {
|
|
28
|
+
path: string;
|
|
29
|
+
kind: 'less-vars' | 'sass-vars' | 'css-vars' | 'tailwind-config' | 'antd-config-provider' | 'theme-file';
|
|
30
|
+
};
|
|
31
|
+
export type VisualToken = {
|
|
32
|
+
name: string;
|
|
33
|
+
value: string;
|
|
34
|
+
source: string;
|
|
35
|
+
};
|
|
36
|
+
export type ConventionSample = {
|
|
37
|
+
path: string;
|
|
38
|
+
kind: 'component' | 'service' | 'hook' | 'page';
|
|
39
|
+
};
|
|
40
|
+
export type ExistingSystemReport = {
|
|
41
|
+
archetype: ProjectArchetype;
|
|
42
|
+
scanned: boolean;
|
|
43
|
+
scanSkippedReason?: string;
|
|
44
|
+
visualTokens: {
|
|
45
|
+
colors: VisualToken[];
|
|
46
|
+
spacing: VisualToken[];
|
|
47
|
+
typography: VisualToken[];
|
|
48
|
+
radii: VisualToken[];
|
|
49
|
+
sources: VisualTokenSource[];
|
|
50
|
+
};
|
|
51
|
+
conventions: {
|
|
52
|
+
componentNaming: 'PascalCase' | 'kebab-case' | 'mixed' | 'unknown';
|
|
53
|
+
componentDir: string | null;
|
|
54
|
+
serviceDir: string | null;
|
|
55
|
+
hookDir: string | null;
|
|
56
|
+
samples: ConventionSample[];
|
|
57
|
+
};
|
|
58
|
+
inconsistencies: string[];
|
|
59
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { RequestType } from '../artifacts/artifact-prerequisites.js';
|
|
2
|
+
export type FileCategory = 'source' | 'config' | 'docs' | 'lockfile' | 'test' | 'unknown';
|
|
3
|
+
export type FileBreakdown = {
|
|
4
|
+
category: FileCategory;
|
|
5
|
+
count: number;
|
|
6
|
+
examples: string[];
|
|
7
|
+
};
|
|
8
|
+
export type TypeSanityReport = {
|
|
9
|
+
declaredType: RequestType;
|
|
10
|
+
gitAvailable: boolean;
|
|
11
|
+
changedFiles: string[];
|
|
12
|
+
breakdown: FileBreakdown[];
|
|
13
|
+
suggestedTypes: ReadonlyArray<RequestType>;
|
|
14
|
+
consistent: boolean;
|
|
15
|
+
rationale: string;
|
|
16
|
+
};
|
|
17
|
+
export type TypeSanityOptions = {
|
|
18
|
+
projectRoot: string;
|
|
19
|
+
declaredType: RequestType;
|
|
20
|
+
/** Compare working tree against this ref. Default 'HEAD' (covers staged + unstaged). */
|
|
21
|
+
baseRef?: string;
|
|
22
|
+
};
|
|
23
|
+
export declare function checkTypeSanity(options: TypeSanityOptions): TypeSanityReport;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { extname, basename } from 'node:path';
|
|
3
|
+
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.vue', '.svelte', '.py', '.go', '.rs', '.java', '.kt', '.swift', '.cpp', '.c', '.h', '.cs', '.rb', '.php', '.scala', '.dart', '.less', '.scss', '.sass', '.css']);
|
|
4
|
+
const DOCS_EXTENSIONS = new Set(['.md', '.mdx', '.rst', '.txt']);
|
|
5
|
+
const LOCKFILE_NAMES = new Set(['pnpm-lock.yaml', 'package-lock.json', 'yarn.lock', 'bun.lockb', 'Cargo.lock', 'Gemfile.lock', 'composer.lock', 'go.sum', 'poetry.lock']);
|
|
6
|
+
const CONFIG_NAMES = new Set(['package.json', 'tsconfig.json', 'tsconfig.base.json', 'vite.config.ts', 'vite.config.js', 'webpack.config.js', 'next.config.js', 'next.config.ts', '.eslintrc', '.eslintrc.js', '.eslintrc.cjs', '.eslintrc.json', '.prettierrc', '.prettierrc.json', 'eslint.config.js', '.gitignore', '.npmrc', 'docker-compose.yml', 'docker-compose.yaml', 'Dockerfile', 'Makefile', '.editorconfig', 'tailwind.config.js', 'tailwind.config.ts', 'postcss.config.js', 'commitlint.config.js', 'lefthook.yml', 'turbo.json', 'lerna.json', 'pnpm-workspace.yaml', 'nx.json']);
|
|
7
|
+
const CONFIG_EXTENSIONS = new Set(['.toml', '.ini', '.cfg', '.env']);
|
|
8
|
+
function classifyFile(filePath) {
|
|
9
|
+
const name = basename(filePath);
|
|
10
|
+
const ext = extname(filePath).toLowerCase();
|
|
11
|
+
if (LOCKFILE_NAMES.has(name))
|
|
12
|
+
return 'lockfile';
|
|
13
|
+
if (CONFIG_NAMES.has(name))
|
|
14
|
+
return 'config';
|
|
15
|
+
if (CONFIG_EXTENSIONS.has(ext))
|
|
16
|
+
return 'config';
|
|
17
|
+
if (filePath.startsWith('.github/') || filePath.includes('/workflows/') || name === 'release.yml' || name.endsWith('.yml') || name.endsWith('.yaml'))
|
|
18
|
+
return 'config';
|
|
19
|
+
if (DOCS_EXTENSIONS.has(ext))
|
|
20
|
+
return 'docs';
|
|
21
|
+
// Test files: anything under tests/, __tests__/, or matching *.test.*, *.spec.*
|
|
22
|
+
if (/\b(?:tests?|__tests__|__mocks__|spec)\b/.test(filePath) || /\.(test|spec)\.[a-z]+$/i.test(name))
|
|
23
|
+
return 'test';
|
|
24
|
+
if (SOURCE_EXTENSIONS.has(ext))
|
|
25
|
+
return 'source';
|
|
26
|
+
return 'unknown';
|
|
27
|
+
}
|
|
28
|
+
function tryGitDiffFiles(projectRoot, baseRef) {
|
|
29
|
+
try {
|
|
30
|
+
// Combine: tracked changes vs baseRef + untracked files. Use porcelain status for untracked too.
|
|
31
|
+
const trackedRaw = execFileSync('git', ['-C', projectRoot, 'diff', '--name-only', baseRef], { encoding: 'utf8' });
|
|
32
|
+
const tracked = trackedRaw.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
33
|
+
const untrackedRaw = execFileSync('git', ['-C', projectRoot, 'ls-files', '--others', '--exclude-standard'], { encoding: 'utf8' });
|
|
34
|
+
const untracked = untrackedRaw.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
35
|
+
const merged = Array.from(new Set([...tracked, ...untracked]));
|
|
36
|
+
return { ok: true, files: merged };
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return { ok: false, files: [] };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function buildBreakdown(files) {
|
|
43
|
+
const grouped = new Map();
|
|
44
|
+
for (const file of files) {
|
|
45
|
+
const category = classifyFile(file);
|
|
46
|
+
const list = grouped.get(category) ?? [];
|
|
47
|
+
list.push(file);
|
|
48
|
+
grouped.set(category, list);
|
|
49
|
+
}
|
|
50
|
+
return Array.from(grouped.entries()).map(([category, examples]) => ({
|
|
51
|
+
category,
|
|
52
|
+
count: examples.length,
|
|
53
|
+
examples: examples.slice(0, 5)
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
function suggestTypes(breakdown) {
|
|
57
|
+
const counts = { source: 0, config: 0, docs: 0, lockfile: 0, test: 0, unknown: 0 };
|
|
58
|
+
for (const entry of breakdown)
|
|
59
|
+
counts[entry.category] = entry.count;
|
|
60
|
+
const hasSource = counts.source > 0;
|
|
61
|
+
const hasConfig = counts.config > 0;
|
|
62
|
+
const hasDocs = counts.docs > 0;
|
|
63
|
+
const hasLockfile = counts.lockfile > 0;
|
|
64
|
+
const hasTest = counts.test > 0;
|
|
65
|
+
if (!hasSource && !hasConfig && hasDocs)
|
|
66
|
+
return ['docs'];
|
|
67
|
+
if (!hasSource && !hasDocs && hasConfig)
|
|
68
|
+
return ['config'];
|
|
69
|
+
if (!hasSource && !hasDocs && !hasConfig && hasLockfile)
|
|
70
|
+
return ['chore'];
|
|
71
|
+
if (!hasSource && !hasDocs && !hasConfig && !hasLockfile && hasTest)
|
|
72
|
+
return ['bugfix', 'refactor'];
|
|
73
|
+
if (hasSource)
|
|
74
|
+
return ['feature', 'bugfix', 'refactor'];
|
|
75
|
+
return ['feature', 'bugfix', 'refactor', 'config', 'docs', 'chore'];
|
|
76
|
+
}
|
|
77
|
+
function isConsistent(declared, suggested) {
|
|
78
|
+
return suggested.includes(declared);
|
|
79
|
+
}
|
|
80
|
+
function buildRationale(declared, breakdown, suggested, consistent) {
|
|
81
|
+
const summary = breakdown.length === 0
|
|
82
|
+
? 'no changed files detected'
|
|
83
|
+
: breakdown.map((entry) => `${entry.category}=${entry.count}`).join(', ');
|
|
84
|
+
if (consistent) {
|
|
85
|
+
return `declared --type=${declared} is consistent with the changed files (${summary})`;
|
|
86
|
+
}
|
|
87
|
+
return `declared --type=${declared} disagrees with the changed files (${summary}); suggested types: ${suggested.join(' | ')}`;
|
|
88
|
+
}
|
|
89
|
+
export function checkTypeSanity(options) {
|
|
90
|
+
const baseRef = options.baseRef ?? 'HEAD';
|
|
91
|
+
const { ok: gitAvailable, files } = tryGitDiffFiles(options.projectRoot, baseRef);
|
|
92
|
+
const breakdown = buildBreakdown(files);
|
|
93
|
+
const suggested = suggestTypes(breakdown);
|
|
94
|
+
const consistent = !gitAvailable ? true : files.length === 0 ? true : isConsistent(options.declaredType, suggested);
|
|
95
|
+
return {
|
|
96
|
+
declaredType: options.declaredType,
|
|
97
|
+
gitAvailable,
|
|
98
|
+
changedFiles: files,
|
|
99
|
+
breakdown,
|
|
100
|
+
suggestedTypes: suggested,
|
|
101
|
+
consistent,
|
|
102
|
+
rationale: !gitAvailable
|
|
103
|
+
? 'git unavailable or not a git repository — type sanity check skipped (returns consistent=true)'
|
|
104
|
+
: files.length === 0
|
|
105
|
+
? `no changes detected against ${baseRef} — type sanity check skipped (returns consistent=true)`
|
|
106
|
+
: buildRationale(options.declaredType, breakdown, suggested, consistent)
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type WorkspaceInitOptions = {
|
|
2
|
+
projectRoot: string;
|
|
3
|
+
sessionId: string;
|
|
4
|
+
};
|
|
5
|
+
export type WorkspaceInitReport = {
|
|
6
|
+
sessionId: string;
|
|
7
|
+
sessionRoot: string;
|
|
8
|
+
created: string[];
|
|
9
|
+
alreadyExisted: string[];
|
|
10
|
+
};
|
|
11
|
+
export declare class InvalidSessionIdError extends Error {
|
|
12
|
+
readonly code = "INVALID_SESSION_ID";
|
|
13
|
+
constructor(message: string);
|
|
14
|
+
}
|
|
15
|
+
export declare function validateSessionId(sessionId: string): void;
|
|
16
|
+
export declare function initWorkspace(options: WorkspaceInitOptions): Promise<WorkspaceInitReport>;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { mkdir } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { isDirectory } from '../../shared/fs.js';
|
|
4
|
+
const SUBDIRECTORIES = [
|
|
5
|
+
'prd/source',
|
|
6
|
+
'prd/requests',
|
|
7
|
+
'ui/requests',
|
|
8
|
+
'rd/requests',
|
|
9
|
+
'qa/test-cases',
|
|
10
|
+
'qa/test-reports',
|
|
11
|
+
'qa/requests',
|
|
12
|
+
'sc',
|
|
13
|
+
'txt',
|
|
14
|
+
'system'
|
|
15
|
+
];
|
|
16
|
+
const SESSION_ID_PATTERN = /^\d{4}-\d{2}-\d{2}-[a-z][a-z0-9-]*[a-z0-9]$/;
|
|
17
|
+
const PROHIBITED_SUFFIXES = ['session', 'work', 'task', 'test', 'temp', 'tmp'];
|
|
18
|
+
export class InvalidSessionIdError extends Error {
|
|
19
|
+
code = 'INVALID_SESSION_ID';
|
|
20
|
+
constructor(message) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = 'InvalidSessionIdError';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function validateSessionId(sessionId) {
|
|
26
|
+
if (/^\d+$/.test(sessionId)) {
|
|
27
|
+
throw new InvalidSessionIdError(`Session id "${sessionId}" is numeric-only. Use the format YYYY-MM-DD-<kebab-slug> with a 2-5 word topic description.`);
|
|
28
|
+
}
|
|
29
|
+
if (/^\d{8}T\d{6}$/.test(sessionId) || /^\d{8}$/.test(sessionId)) {
|
|
30
|
+
throw new InvalidSessionIdError(`Session id "${sessionId}" looks like a bare timestamp. Use YYYY-MM-DD-<kebab-slug>.`);
|
|
31
|
+
}
|
|
32
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(sessionId)) {
|
|
33
|
+
throw new InvalidSessionIdError(`Session id "${sessionId}" is a bare date. Append a 2-5 word topic slug (e.g. "${sessionId}-add-user-auth").`);
|
|
34
|
+
}
|
|
35
|
+
if (!SESSION_ID_PATTERN.test(sessionId)) {
|
|
36
|
+
throw new InvalidSessionIdError(`Session id "${sessionId}" must match YYYY-MM-DD-<kebab-slug>, all lowercase, dashes only.`);
|
|
37
|
+
}
|
|
38
|
+
const suffix = sessionId.slice(11); // strip "YYYY-MM-DD-"
|
|
39
|
+
if (PROHIBITED_SUFFIXES.includes(suffix)) {
|
|
40
|
+
throw new InvalidSessionIdError(`Session id suffix "${suffix}" is a generic placeholder. Use a real topic slug (e.g. "add-user-auth", "v3-indicator-model").`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export async function initWorkspace(options) {
|
|
44
|
+
validateSessionId(options.sessionId);
|
|
45
|
+
const sessionRoot = join(options.projectRoot, '.peaks', options.sessionId);
|
|
46
|
+
const created = [];
|
|
47
|
+
const alreadyExisted = [];
|
|
48
|
+
if (await isDirectory(sessionRoot)) {
|
|
49
|
+
alreadyExisted.push('.');
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
await mkdir(sessionRoot, { recursive: true });
|
|
53
|
+
created.push('.');
|
|
54
|
+
}
|
|
55
|
+
for (const sub of SUBDIRECTORIES) {
|
|
56
|
+
const full = join(sessionRoot, sub);
|
|
57
|
+
if (await isDirectory(full)) {
|
|
58
|
+
alreadyExisted.push(sub);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
await mkdir(full, { recursive: true });
|
|
62
|
+
created.push(sub);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { sessionId: options.sessionId, sessionRoot, created, alreadyExisted };
|
|
66
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const CLI_VERSION = "1.0.
|
|
1
|
+
export declare const CLI_VERSION = "1.0.18";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const CLI_VERSION = "1.0.
|
|
1
|
+
export const CLI_VERSION = "1.0.18";
|