sequant 1.18.0 → 1.19.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-plugin/plugin.json +1 -1
- package/dist/bin/cli.js +7 -0
- package/dist/src/commands/conventions.d.ts +9 -0
- package/dist/src/commands/conventions.js +61 -0
- package/dist/src/commands/init.js +12 -0
- package/dist/src/lib/conventions-detector.d.ts +62 -0
- package/dist/src/lib/conventions-detector.js +510 -0
- package/dist/src/lib/settings.d.ts +8 -0
- package/dist/src/lib/settings.js +1 -0
- package/dist/src/lib/stacks.d.ts +4 -2
- package/dist/src/lib/stacks.js +43 -3
- package/dist/src/lib/workflow/state-schema.d.ts +5 -5
- package/package.json +1 -1
- package/templates/skills/exec/SKILL.md +1086 -29
- package/templates/skills/qa/SKILL.md +1732 -156
- package/templates/skills/test/SKILL.md +246 -1
package/dist/bin/cli.js
CHANGED
|
@@ -45,6 +45,7 @@ import { dashboardCommand } from "../src/commands/dashboard.js";
|
|
|
45
45
|
import { stateInitCommand, stateRebuildCommand, stateCleanCommand, } from "../src/commands/state.js";
|
|
46
46
|
import { syncCommand, areSkillsOutdated } from "../src/commands/sync.js";
|
|
47
47
|
import { mergeCommand } from "../src/commands/merge.js";
|
|
48
|
+
import { conventionsCommand } from "../src/commands/conventions.js";
|
|
48
49
|
import { getManifest } from "../src/lib/manifest.js";
|
|
49
50
|
const program = new Command();
|
|
50
51
|
// Handle --no-color before parsing
|
|
@@ -159,6 +160,12 @@ program
|
|
|
159
160
|
.option("--json", "Output as JSON")
|
|
160
161
|
.option("-v, --verbose", "Enable verbose output")
|
|
161
162
|
.action(mergeCommand);
|
|
163
|
+
program
|
|
164
|
+
.command("conventions")
|
|
165
|
+
.description("View and manage codebase conventions")
|
|
166
|
+
.option("--detect", "Re-run convention detection")
|
|
167
|
+
.option("--reset", "Clear detected conventions (keep manual)")
|
|
168
|
+
.action(conventionsCommand);
|
|
162
169
|
program
|
|
163
170
|
.command("logs")
|
|
164
171
|
.description("View and analyze workflow run logs")
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sequant conventions - View and manage codebase conventions
|
|
3
|
+
*/
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
import { detectAndSaveConventions, loadConventions, formatConventions, CONVENTIONS_PATH, } from "../lib/conventions-detector.js";
|
|
6
|
+
import { fileExists, writeFile } from "../lib/fs.js";
|
|
7
|
+
export async function conventionsCommand(options) {
|
|
8
|
+
if (options.reset) {
|
|
9
|
+
await handleReset();
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
if (options.detect) {
|
|
13
|
+
await handleDetect();
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
// Default: show current conventions
|
|
17
|
+
await handleShow();
|
|
18
|
+
}
|
|
19
|
+
async function handleDetect() {
|
|
20
|
+
console.log(chalk.blue("Detecting codebase conventions..."));
|
|
21
|
+
const result = await detectAndSaveConventions(process.cwd());
|
|
22
|
+
const count = Object.keys(result.detected).length;
|
|
23
|
+
console.log(chalk.green(`\nDetected ${count} conventions:`));
|
|
24
|
+
console.log(formatConventions(result));
|
|
25
|
+
console.log(chalk.gray(`\nSaved to ${CONVENTIONS_PATH}`));
|
|
26
|
+
}
|
|
27
|
+
async function handleReset() {
|
|
28
|
+
const existing = await loadConventions();
|
|
29
|
+
if (!existing) {
|
|
30
|
+
console.log(chalk.yellow("No conventions file found. Nothing to reset."));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
// Keep manual entries, clear detected
|
|
34
|
+
const reset = {
|
|
35
|
+
detected: {},
|
|
36
|
+
manual: existing.manual,
|
|
37
|
+
detectedAt: "",
|
|
38
|
+
};
|
|
39
|
+
await writeFile(CONVENTIONS_PATH, JSON.stringify(reset, null, 2));
|
|
40
|
+
console.log(chalk.green("Detected conventions cleared. Manual entries preserved."));
|
|
41
|
+
if (Object.keys(existing.manual).length > 0) {
|
|
42
|
+
console.log(chalk.gray("\nManual entries kept:"));
|
|
43
|
+
for (const [key, value] of Object.entries(existing.manual)) {
|
|
44
|
+
console.log(chalk.gray(` ${key}: ${value}`));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async function handleShow() {
|
|
49
|
+
if (!(await fileExists(CONVENTIONS_PATH))) {
|
|
50
|
+
console.log(chalk.yellow("No conventions detected yet."));
|
|
51
|
+
console.log(chalk.gray("Run 'sequant conventions --detect' or 'sequant init' to detect conventions."));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const conventions = await loadConventions();
|
|
55
|
+
if (!conventions) {
|
|
56
|
+
console.log(chalk.yellow("Could not read conventions file."));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
console.log(formatConventions(conventions));
|
|
60
|
+
console.log(chalk.gray(`\nEdit ${CONVENTIONS_PATH} to add manual overrides.`));
|
|
61
|
+
}
|
|
@@ -9,6 +9,7 @@ import { copyTemplates } from "../lib/templates.js";
|
|
|
9
9
|
import { createManifest } from "../lib/manifest.js";
|
|
10
10
|
import { saveConfig } from "../lib/config.js";
|
|
11
11
|
import { createDefaultSettings } from "../lib/settings.js";
|
|
12
|
+
import { detectAndSaveConventions } from "../lib/conventions-detector.js";
|
|
12
13
|
import { fileExists, ensureDir, readFile, writeFile } from "../lib/fs.js";
|
|
13
14
|
import { commandExists, isGhAuthenticated, getInstallHint, } from "../lib/system.js";
|
|
14
15
|
import { shouldUseInteractiveMode, getNonInteractiveReason, } from "../lib/tty.js";
|
|
@@ -347,6 +348,17 @@ export async function initCommand(options) {
|
|
|
347
348
|
settingsSpinner.start();
|
|
348
349
|
await createDefaultSettings();
|
|
349
350
|
settingsSpinner.succeed("Created default settings");
|
|
351
|
+
// Detect codebase conventions
|
|
352
|
+
const conventionsSpinner = ui.spinner("Detecting codebase conventions...");
|
|
353
|
+
conventionsSpinner.start();
|
|
354
|
+
try {
|
|
355
|
+
const conventions = await detectAndSaveConventions(process.cwd());
|
|
356
|
+
const count = Object.keys(conventions.detected).length;
|
|
357
|
+
conventionsSpinner.succeed(`Detected ${count} codebase conventions`);
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
conventionsSpinner.warn("Could not detect conventions (non-blocking)");
|
|
361
|
+
}
|
|
350
362
|
// Copy templates (with symlinks for scripts unless --no-symlinks)
|
|
351
363
|
const templatesSpinner = ui.spinner("Copying templates...");
|
|
352
364
|
templatesSpinner.start();
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codebase conventions detector
|
|
3
|
+
*
|
|
4
|
+
* Deterministic detection of observable codebase patterns.
|
|
5
|
+
* No AI/ML — just file scanning and pattern matching.
|
|
6
|
+
*/
|
|
7
|
+
/** Path to conventions file */
|
|
8
|
+
export declare const CONVENTIONS_PATH = ".sequant/conventions.json";
|
|
9
|
+
/**
|
|
10
|
+
* A single detected convention
|
|
11
|
+
*/
|
|
12
|
+
export interface Convention {
|
|
13
|
+
/** Machine-readable key */
|
|
14
|
+
key: string;
|
|
15
|
+
/** Human-readable label */
|
|
16
|
+
label: string;
|
|
17
|
+
/** Detected value */
|
|
18
|
+
value: string;
|
|
19
|
+
/** How it was detected */
|
|
20
|
+
source: "detected" | "manual";
|
|
21
|
+
/** What evidence triggered detection */
|
|
22
|
+
evidence?: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Full conventions file schema
|
|
26
|
+
*/
|
|
27
|
+
export interface ConventionsFile {
|
|
28
|
+
/** Auto-detected conventions */
|
|
29
|
+
detected: Record<string, string>;
|
|
30
|
+
/** User-provided overrides */
|
|
31
|
+
manual: Record<string, string>;
|
|
32
|
+
/** When detection was last run */
|
|
33
|
+
detectedAt: string;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Run all convention detectors
|
|
37
|
+
*/
|
|
38
|
+
export declare function detectConventions(projectRoot: string): Promise<Convention[]>;
|
|
39
|
+
/**
|
|
40
|
+
* Load existing conventions file
|
|
41
|
+
*/
|
|
42
|
+
export declare function loadConventions(): Promise<ConventionsFile | null>;
|
|
43
|
+
/**
|
|
44
|
+
* Save conventions, preserving manual entries
|
|
45
|
+
*/
|
|
46
|
+
export declare function saveConventions(detected: Convention[]): Promise<ConventionsFile>;
|
|
47
|
+
/**
|
|
48
|
+
* Get merged conventions (manual overrides detected)
|
|
49
|
+
*/
|
|
50
|
+
export declare function getMergedConventions(file: ConventionsFile): Record<string, string>;
|
|
51
|
+
/**
|
|
52
|
+
* Format conventions for display
|
|
53
|
+
*/
|
|
54
|
+
export declare function formatConventions(file: ConventionsFile): string;
|
|
55
|
+
/**
|
|
56
|
+
* Detect and save conventions in one call
|
|
57
|
+
*/
|
|
58
|
+
export declare function detectAndSaveConventions(projectRoot: string): Promise<ConventionsFile>;
|
|
59
|
+
/**
|
|
60
|
+
* Format conventions as context for AI skills
|
|
61
|
+
*/
|
|
62
|
+
export declare function formatConventionsForContext(file: ConventionsFile): string;
|
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codebase conventions detector
|
|
3
|
+
*
|
|
4
|
+
* Deterministic detection of observable codebase patterns.
|
|
5
|
+
* No AI/ML — just file scanning and pattern matching.
|
|
6
|
+
*/
|
|
7
|
+
import { readdir, stat } from "fs/promises";
|
|
8
|
+
import { join, extname } from "path";
|
|
9
|
+
import { fileExists, readFile, writeFile, ensureDir } from "./fs.js";
|
|
10
|
+
/** Path to conventions file */
|
|
11
|
+
export const CONVENTIONS_PATH = ".sequant/conventions.json";
|
|
12
|
+
/** Directories to skip during scanning */
|
|
13
|
+
const SKIP_DIRS = new Set([
|
|
14
|
+
"node_modules",
|
|
15
|
+
".git",
|
|
16
|
+
"dist",
|
|
17
|
+
"build",
|
|
18
|
+
".next",
|
|
19
|
+
".nuxt",
|
|
20
|
+
".output",
|
|
21
|
+
"__pycache__",
|
|
22
|
+
"target",
|
|
23
|
+
".claude",
|
|
24
|
+
".sequant",
|
|
25
|
+
"coverage",
|
|
26
|
+
".turbo",
|
|
27
|
+
".cache",
|
|
28
|
+
"vendor",
|
|
29
|
+
]);
|
|
30
|
+
/**
|
|
31
|
+
* Collect source files up to a limit, skipping irrelevant directories
|
|
32
|
+
*/
|
|
33
|
+
async function collectFiles(dir, extensions, maxFiles, depth = 0) {
|
|
34
|
+
if (depth > 5)
|
|
35
|
+
return [];
|
|
36
|
+
const results = [];
|
|
37
|
+
let entries;
|
|
38
|
+
try {
|
|
39
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return results;
|
|
43
|
+
}
|
|
44
|
+
for (const entry of entries) {
|
|
45
|
+
if (results.length >= maxFiles)
|
|
46
|
+
break;
|
|
47
|
+
if (entry.isDirectory()) {
|
|
48
|
+
if (SKIP_DIRS.has(entry.name) || entry.name.startsWith("."))
|
|
49
|
+
continue;
|
|
50
|
+
const sub = await collectFiles(join(dir, entry.name), extensions, maxFiles - results.length, depth + 1);
|
|
51
|
+
results.push(...sub);
|
|
52
|
+
}
|
|
53
|
+
else if (entry.isFile() && extensions.has(extname(entry.name))) {
|
|
54
|
+
results.push(join(dir, entry.name));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return results;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Count occurrences of a pattern in file contents
|
|
61
|
+
*/
|
|
62
|
+
async function countPattern(files, pattern, maxFiles = 50) {
|
|
63
|
+
let count = 0;
|
|
64
|
+
for (const file of files.slice(0, maxFiles)) {
|
|
65
|
+
try {
|
|
66
|
+
const content = await readFile(file);
|
|
67
|
+
const matches = content.match(pattern);
|
|
68
|
+
if (matches)
|
|
69
|
+
count += matches.length;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// Skip unreadable files
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return count;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Detect test file naming convention
|
|
79
|
+
*/
|
|
80
|
+
async function detectTestPattern(root) {
|
|
81
|
+
const testFiles = await collectFiles(root, new Set([".ts", ".tsx", ".js", ".jsx"]), 500);
|
|
82
|
+
const dotTest = testFiles.filter((f) => /\.test\.[jt]sx?$/.test(f));
|
|
83
|
+
const dotSpec = testFiles.filter((f) => /\.spec\.[jt]sx?$/.test(f));
|
|
84
|
+
const underscoreTests = testFiles.filter((f) => f.includes("__tests__/"));
|
|
85
|
+
if (dotTest.length === 0 &&
|
|
86
|
+
dotSpec.length === 0 &&
|
|
87
|
+
underscoreTests.length === 0) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
let value;
|
|
91
|
+
let evidence;
|
|
92
|
+
if (dotTest.length >= dotSpec.length &&
|
|
93
|
+
dotTest.length >= underscoreTests.length) {
|
|
94
|
+
value = "*.test.ts";
|
|
95
|
+
evidence = `${dotTest.length} .test.* files found`;
|
|
96
|
+
}
|
|
97
|
+
else if (dotSpec.length >= underscoreTests.length) {
|
|
98
|
+
value = "*.spec.ts";
|
|
99
|
+
evidence = `${dotSpec.length} .spec.* files found`;
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
value = "__tests__/";
|
|
103
|
+
evidence = `${underscoreTests.length} files in __tests__/ directories`;
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
key: "testFilePattern",
|
|
107
|
+
label: "Test file pattern",
|
|
108
|
+
value,
|
|
109
|
+
source: "detected",
|
|
110
|
+
evidence,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Detect export style preference (named vs default)
|
|
115
|
+
*/
|
|
116
|
+
async function detectExportStyle(root) {
|
|
117
|
+
const srcDir = join(root, "src");
|
|
118
|
+
const searchDir = (await fileExists(srcDir)) ? srcDir : root;
|
|
119
|
+
const files = await collectFiles(searchDir, new Set([".ts", ".tsx", ".js", ".jsx"]), 100);
|
|
120
|
+
if (files.length === 0)
|
|
121
|
+
return null;
|
|
122
|
+
const defaultExports = await countPattern(files, /export\s+default\b/g, 50);
|
|
123
|
+
const namedExports = await countPattern(files, /export\s+(?:async\s+)?(?:function|class|const|let|interface|type|enum)\b/g, 50);
|
|
124
|
+
if (defaultExports === 0 && namedExports === 0)
|
|
125
|
+
return null;
|
|
126
|
+
const total = defaultExports + namedExports;
|
|
127
|
+
const namedRatio = namedExports / total;
|
|
128
|
+
let value;
|
|
129
|
+
if (namedRatio > 0.7) {
|
|
130
|
+
value = "named";
|
|
131
|
+
}
|
|
132
|
+
else if (namedRatio < 0.3) {
|
|
133
|
+
value = "default";
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
value = "mixed";
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
key: "exportStyle",
|
|
140
|
+
label: "Export style",
|
|
141
|
+
value,
|
|
142
|
+
source: "detected",
|
|
143
|
+
evidence: `${namedExports} named, ${defaultExports} default exports`,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Detect async pattern preference
|
|
148
|
+
*/
|
|
149
|
+
async function detectAsyncPattern(root) {
|
|
150
|
+
const srcDir = join(root, "src");
|
|
151
|
+
const searchDir = (await fileExists(srcDir)) ? srcDir : root;
|
|
152
|
+
const files = await collectFiles(searchDir, new Set([".ts", ".tsx", ".js", ".jsx"]), 100);
|
|
153
|
+
if (files.length === 0)
|
|
154
|
+
return null;
|
|
155
|
+
const awaitCount = await countPattern(files, /\bawait\b/g, 50);
|
|
156
|
+
const thenCount = await countPattern(files, /\.then\s*\(/g, 50);
|
|
157
|
+
if (awaitCount === 0 && thenCount === 0)
|
|
158
|
+
return null;
|
|
159
|
+
const total = awaitCount + thenCount;
|
|
160
|
+
const awaitRatio = awaitCount / total;
|
|
161
|
+
let value;
|
|
162
|
+
if (awaitRatio > 0.7) {
|
|
163
|
+
value = "async/await";
|
|
164
|
+
}
|
|
165
|
+
else if (awaitRatio < 0.3) {
|
|
166
|
+
value = "promise-chains";
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
value = "mixed";
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
key: "asyncPattern",
|
|
173
|
+
label: "Async pattern",
|
|
174
|
+
value,
|
|
175
|
+
source: "detected",
|
|
176
|
+
evidence: `${awaitCount} await, ${thenCount} .then() usages`,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Detect TypeScript strictness
|
|
181
|
+
*/
|
|
182
|
+
async function detectTypeScriptConfig(root) {
|
|
183
|
+
const tsConfigPath = join(root, "tsconfig.json");
|
|
184
|
+
if (!(await fileExists(tsConfigPath)))
|
|
185
|
+
return null;
|
|
186
|
+
try {
|
|
187
|
+
const content = await readFile(tsConfigPath);
|
|
188
|
+
// Strip comments (single-line) for JSON parsing
|
|
189
|
+
const stripped = content.replace(/\/\/.*$/gm, "");
|
|
190
|
+
const config = JSON.parse(stripped);
|
|
191
|
+
const strict = config?.compilerOptions?.strict;
|
|
192
|
+
return {
|
|
193
|
+
key: "typescriptStrict",
|
|
194
|
+
label: "TypeScript strict mode",
|
|
195
|
+
value: strict ? "enabled" : "disabled",
|
|
196
|
+
source: "detected",
|
|
197
|
+
evidence: `tsconfig.json compilerOptions.strict = ${strict}`,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Detect source directory structure
|
|
206
|
+
*/
|
|
207
|
+
async function detectSourceStructure(root) {
|
|
208
|
+
const candidates = [
|
|
209
|
+
{ path: "src", label: "src/" },
|
|
210
|
+
{ path: "lib", label: "lib/" },
|
|
211
|
+
{ path: "app", label: "app/" },
|
|
212
|
+
{ path: "pages", label: "pages/" },
|
|
213
|
+
];
|
|
214
|
+
const found = [];
|
|
215
|
+
for (const c of candidates) {
|
|
216
|
+
const fullPath = join(root, c.path);
|
|
217
|
+
try {
|
|
218
|
+
const s = await stat(fullPath);
|
|
219
|
+
if (s.isDirectory())
|
|
220
|
+
found.push(c.label);
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
// doesn't exist
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (found.length === 0)
|
|
227
|
+
return null;
|
|
228
|
+
return {
|
|
229
|
+
key: "sourceStructure",
|
|
230
|
+
label: "Source directory structure",
|
|
231
|
+
value: found.join(", "),
|
|
232
|
+
source: "detected",
|
|
233
|
+
evidence: `Found directories: ${found.join(", ")}`,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Detect package manager from lockfiles
|
|
238
|
+
*/
|
|
239
|
+
async function detectPackageManagerConvention(root) {
|
|
240
|
+
const lockfiles = [
|
|
241
|
+
{ file: "bun.lockb", manager: "bun" },
|
|
242
|
+
{ file: "bun.lock", manager: "bun" },
|
|
243
|
+
{ file: "pnpm-lock.yaml", manager: "pnpm" },
|
|
244
|
+
{ file: "yarn.lock", manager: "yarn" },
|
|
245
|
+
{ file: "package-lock.json", manager: "npm" },
|
|
246
|
+
];
|
|
247
|
+
for (const { file, manager } of lockfiles) {
|
|
248
|
+
if (await fileExists(join(root, file))) {
|
|
249
|
+
return {
|
|
250
|
+
key: "packageManager",
|
|
251
|
+
label: "Package manager",
|
|
252
|
+
value: manager,
|
|
253
|
+
source: "detected",
|
|
254
|
+
evidence: `Found ${file}`,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Detect indentation style from source files
|
|
262
|
+
*/
|
|
263
|
+
async function detectIndentation(root) {
|
|
264
|
+
const srcDir = join(root, "src");
|
|
265
|
+
const searchDir = (await fileExists(srcDir)) ? srcDir : root;
|
|
266
|
+
const files = await collectFiles(searchDir, new Set([".ts", ".tsx", ".js", ".jsx"]), 30);
|
|
267
|
+
if (files.length === 0)
|
|
268
|
+
return null;
|
|
269
|
+
let twoSpace = 0;
|
|
270
|
+
let fourSpace = 0;
|
|
271
|
+
let tabs = 0;
|
|
272
|
+
for (const file of files.slice(0, 20)) {
|
|
273
|
+
try {
|
|
274
|
+
const content = await readFile(file);
|
|
275
|
+
const lines = content.split("\n").slice(0, 50);
|
|
276
|
+
for (const line of lines) {
|
|
277
|
+
if (/^\t/.test(line))
|
|
278
|
+
tabs++;
|
|
279
|
+
else if (/^ {2}[^ ]/.test(line))
|
|
280
|
+
twoSpace++;
|
|
281
|
+
else if (/^ {4}[^ ]/.test(line))
|
|
282
|
+
fourSpace++;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
// skip
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const total = twoSpace + fourSpace + tabs;
|
|
290
|
+
if (total === 0)
|
|
291
|
+
return null;
|
|
292
|
+
let value;
|
|
293
|
+
if (tabs > twoSpace && tabs > fourSpace) {
|
|
294
|
+
value = "tabs";
|
|
295
|
+
}
|
|
296
|
+
else if (twoSpace >= fourSpace) {
|
|
297
|
+
value = "2 spaces";
|
|
298
|
+
}
|
|
299
|
+
else {
|
|
300
|
+
value = "4 spaces";
|
|
301
|
+
}
|
|
302
|
+
return {
|
|
303
|
+
key: "indentation",
|
|
304
|
+
label: "Indentation",
|
|
305
|
+
value,
|
|
306
|
+
source: "detected",
|
|
307
|
+
evidence: `${twoSpace} two-space, ${fourSpace} four-space, ${tabs} tab-indented lines`,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Detect semicolon usage
|
|
312
|
+
*/
|
|
313
|
+
async function detectSemicolons(root) {
|
|
314
|
+
const srcDir = join(root, "src");
|
|
315
|
+
const searchDir = (await fileExists(srcDir)) ? srcDir : root;
|
|
316
|
+
const files = await collectFiles(searchDir, new Set([".ts", ".tsx", ".js", ".jsx"]), 30);
|
|
317
|
+
if (files.length === 0)
|
|
318
|
+
return null;
|
|
319
|
+
let withSemicolon = 0;
|
|
320
|
+
let withoutSemicolon = 0;
|
|
321
|
+
for (const file of files.slice(0, 20)) {
|
|
322
|
+
try {
|
|
323
|
+
const content = await readFile(file);
|
|
324
|
+
const lines = content.split("\n");
|
|
325
|
+
for (const line of lines) {
|
|
326
|
+
const trimmed = line.trim();
|
|
327
|
+
// Skip empty lines, comments, opening/closing brackets
|
|
328
|
+
if (!trimmed ||
|
|
329
|
+
trimmed.startsWith("//") ||
|
|
330
|
+
trimmed.startsWith("/*") ||
|
|
331
|
+
trimmed.startsWith("*") ||
|
|
332
|
+
/^[{}()[\]]$/.test(trimmed) ||
|
|
333
|
+
/^import\s/.test(trimmed) ||
|
|
334
|
+
/^export\s/.test(trimmed))
|
|
335
|
+
continue;
|
|
336
|
+
if (trimmed.endsWith(";"))
|
|
337
|
+
withSemicolon++;
|
|
338
|
+
else if (trimmed.endsWith(")") ||
|
|
339
|
+
trimmed.endsWith('"') ||
|
|
340
|
+
trimmed.endsWith("'") ||
|
|
341
|
+
trimmed.endsWith("`") ||
|
|
342
|
+
/\w$/.test(trimmed))
|
|
343
|
+
withoutSemicolon++;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
// skip
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const total = withSemicolon + withoutSemicolon;
|
|
351
|
+
if (total === 0)
|
|
352
|
+
return null;
|
|
353
|
+
const semiRatio = withSemicolon / total;
|
|
354
|
+
const value = semiRatio > 0.6 ? "required" : semiRatio < 0.3 ? "omitted" : "mixed";
|
|
355
|
+
return {
|
|
356
|
+
key: "semicolons",
|
|
357
|
+
label: "Semicolons",
|
|
358
|
+
value,
|
|
359
|
+
source: "detected",
|
|
360
|
+
evidence: `${withSemicolon} with, ${withoutSemicolon} without semicolons`,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Detect component directory structure (for frontend projects)
|
|
365
|
+
*/
|
|
366
|
+
async function detectComponentStructure(root) {
|
|
367
|
+
const candidates = [
|
|
368
|
+
"src/components",
|
|
369
|
+
"components",
|
|
370
|
+
"src/app",
|
|
371
|
+
"app",
|
|
372
|
+
"src/pages",
|
|
373
|
+
"pages",
|
|
374
|
+
];
|
|
375
|
+
for (const candidate of candidates) {
|
|
376
|
+
const dirPath = join(root, candidate);
|
|
377
|
+
try {
|
|
378
|
+
const s = await stat(dirPath);
|
|
379
|
+
if (s.isDirectory()) {
|
|
380
|
+
return {
|
|
381
|
+
key: "componentDir",
|
|
382
|
+
label: "Component directory",
|
|
383
|
+
value: candidate + "/",
|
|
384
|
+
source: "detected",
|
|
385
|
+
evidence: `Directory exists: ${candidate}/`,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
// doesn't exist
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Run all convention detectors
|
|
397
|
+
*/
|
|
398
|
+
export async function detectConventions(projectRoot) {
|
|
399
|
+
const detectors = [
|
|
400
|
+
detectTestPattern,
|
|
401
|
+
detectExportStyle,
|
|
402
|
+
detectAsyncPattern,
|
|
403
|
+
detectTypeScriptConfig,
|
|
404
|
+
detectSourceStructure,
|
|
405
|
+
detectPackageManagerConvention,
|
|
406
|
+
detectIndentation,
|
|
407
|
+
detectSemicolons,
|
|
408
|
+
detectComponentStructure,
|
|
409
|
+
];
|
|
410
|
+
const results = [];
|
|
411
|
+
for (const detector of detectors) {
|
|
412
|
+
try {
|
|
413
|
+
const result = await detector(projectRoot);
|
|
414
|
+
if (result)
|
|
415
|
+
results.push(result);
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
// Skip failed detectors
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return results;
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Load existing conventions file
|
|
425
|
+
*/
|
|
426
|
+
export async function loadConventions() {
|
|
427
|
+
if (!(await fileExists(CONVENTIONS_PATH)))
|
|
428
|
+
return null;
|
|
429
|
+
try {
|
|
430
|
+
const content = await readFile(CONVENTIONS_PATH);
|
|
431
|
+
return JSON.parse(content);
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Save conventions, preserving manual entries
|
|
439
|
+
*/
|
|
440
|
+
export async function saveConventions(detected) {
|
|
441
|
+
// Load existing to preserve manual entries
|
|
442
|
+
const existing = await loadConventions();
|
|
443
|
+
const manual = existing?.manual ?? {};
|
|
444
|
+
const detectedMap = {};
|
|
445
|
+
for (const c of detected) {
|
|
446
|
+
detectedMap[c.key] = c.value;
|
|
447
|
+
}
|
|
448
|
+
const conventions = {
|
|
449
|
+
detected: detectedMap,
|
|
450
|
+
manual,
|
|
451
|
+
detectedAt: new Date().toISOString(),
|
|
452
|
+
};
|
|
453
|
+
await ensureDir(".sequant");
|
|
454
|
+
await writeFile(CONVENTIONS_PATH, JSON.stringify(conventions, null, 2));
|
|
455
|
+
return conventions;
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Get merged conventions (manual overrides detected)
|
|
459
|
+
*/
|
|
460
|
+
export function getMergedConventions(file) {
|
|
461
|
+
return { ...file.detected, ...file.manual };
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Format conventions for display
|
|
465
|
+
*/
|
|
466
|
+
export function formatConventions(file) {
|
|
467
|
+
const lines = [];
|
|
468
|
+
lines.push("Detected conventions:");
|
|
469
|
+
const detected = Object.entries(file.detected);
|
|
470
|
+
if (detected.length === 0) {
|
|
471
|
+
lines.push(" (none)");
|
|
472
|
+
}
|
|
473
|
+
else {
|
|
474
|
+
for (const [key, value] of detected) {
|
|
475
|
+
lines.push(` ${key}: ${value}`);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
const manual = Object.entries(file.manual);
|
|
479
|
+
if (manual.length > 0) {
|
|
480
|
+
lines.push("");
|
|
481
|
+
lines.push("Manual overrides:");
|
|
482
|
+
for (const [key, value] of manual) {
|
|
483
|
+
lines.push(` ${key}: ${value}`);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
lines.push("");
|
|
487
|
+
lines.push(`Last detected: ${file.detectedAt}`);
|
|
488
|
+
return lines.join("\n");
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Detect and save conventions in one call
|
|
492
|
+
*/
|
|
493
|
+
export async function detectAndSaveConventions(projectRoot) {
|
|
494
|
+
const conventions = await detectConventions(projectRoot);
|
|
495
|
+
return saveConventions(conventions);
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Format conventions as context for AI skills
|
|
499
|
+
*/
|
|
500
|
+
export function formatConventionsForContext(file) {
|
|
501
|
+
const merged = getMergedConventions(file);
|
|
502
|
+
const entries = Object.entries(merged);
|
|
503
|
+
if (entries.length === 0)
|
|
504
|
+
return "";
|
|
505
|
+
const lines = ["## Codebase Conventions", ""];
|
|
506
|
+
for (const [key, value] of entries) {
|
|
507
|
+
lines.push(`- **${key}**: ${value}`);
|
|
508
|
+
}
|
|
509
|
+
return lines.join("\n");
|
|
510
|
+
}
|