@syntesseraai/opencode-feature-factory 0.2.2 → 0.2.4

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,18 @@
1
+ import type { CommandStep, QualityGateConfig } from './types';
2
+ type BunShell = any;
3
+ /**
4
+ * Resolve the command plan based on configuration and discovery.
5
+ *
6
+ * Resolution order:
7
+ * 1. Configured commands (qualityGate.lint/build/test) - highest priority
8
+ * 2. management/ci.sh (Feature Factory convention) - only if no configured commands
9
+ * 3. Conventional discovery (Node/Rust/Go/Python) - only if no ci.sh
10
+ *
11
+ * @returns Array of command steps to execute, or empty if nothing found
12
+ */
13
+ export declare function resolveCommands(args: {
14
+ $: BunShell;
15
+ directory: string;
16
+ config: QualityGateConfig;
17
+ }): Promise<CommandStep[]>;
18
+ export {};
@@ -0,0 +1,189 @@
1
+ import { DEFAULT_QUALITY_GATE, fileExists, hasConfiguredCommands, readJsonFile, } from './quality-gate-config';
2
+ // ============================================================================
3
+ // Package Manager Detection
4
+ // ============================================================================
5
+ /**
6
+ * Detect the package manager based on lockfile presence.
7
+ * Priority: pnpm > bun > yarn > npm
8
+ */
9
+ async function detectPackageManager($, directory, override) {
10
+ if (override && override !== 'auto') {
11
+ return override;
12
+ }
13
+ // Priority order: pnpm > bun > yarn > npm
14
+ if (await fileExists($, `${directory}/pnpm-lock.yaml`))
15
+ return 'pnpm';
16
+ if (await fileExists($, `${directory}/bun.lockb`))
17
+ return 'bun';
18
+ if (await fileExists($, `${directory}/bun.lock`))
19
+ return 'bun';
20
+ if (await fileExists($, `${directory}/yarn.lock`))
21
+ return 'yarn';
22
+ if (await fileExists($, `${directory}/package-lock.json`))
23
+ return 'npm';
24
+ return 'npm'; // fallback
25
+ }
26
+ /**
27
+ * Build the run command for a given package manager and script name
28
+ */
29
+ function buildRunCommand(pm, script) {
30
+ switch (pm) {
31
+ case 'pnpm':
32
+ return `pnpm -s run ${script}`;
33
+ case 'bun':
34
+ return `bun run ${script}`;
35
+ case 'yarn':
36
+ return `yarn -s ${script}`;
37
+ case 'npm':
38
+ return `npm run -s ${script}`;
39
+ }
40
+ }
41
+ // ============================================================================
42
+ // Node.js Discovery
43
+ // ============================================================================
44
+ /**
45
+ * Discover Node.js lint/build/test commands from package.json scripts
46
+ */
47
+ async function discoverNodeCommands($, directory, pm) {
48
+ const pkgJson = await readJsonFile($, `${directory}/package.json`);
49
+ if (!pkgJson?.scripts)
50
+ return [];
51
+ const scripts = pkgJson.scripts;
52
+ const steps = [];
53
+ // Lint: prefer lint:ci, then lint
54
+ const lintScript = scripts['lint:ci'] ? 'lint:ci' : scripts['lint'] ? 'lint' : null;
55
+ if (lintScript) {
56
+ steps.push({ step: 'lint', cmd: buildRunCommand(pm, lintScript) });
57
+ }
58
+ // Build: prefer build:ci, then build
59
+ const buildScript = scripts['build:ci'] ? 'build:ci' : scripts['build'] ? 'build' : null;
60
+ if (buildScript) {
61
+ steps.push({ step: 'build', cmd: buildRunCommand(pm, buildScript) });
62
+ }
63
+ // Test: prefer test:ci, then test
64
+ const testScript = scripts['test:ci'] ? 'test:ci' : scripts['test'] ? 'test' : null;
65
+ if (testScript) {
66
+ steps.push({ step: 'test', cmd: buildRunCommand(pm, testScript) });
67
+ }
68
+ return steps;
69
+ }
70
+ // ============================================================================
71
+ // Rust Discovery
72
+ // ============================================================================
73
+ /**
74
+ * Discover Rust commands from Cargo.toml presence
75
+ */
76
+ async function discoverRustCommands($, directory, includeClippy) {
77
+ if (!(await fileExists($, `${directory}/Cargo.toml`)))
78
+ return [];
79
+ const steps = [{ step: 'lint (fmt)', cmd: 'cargo fmt --check' }];
80
+ if (includeClippy) {
81
+ steps.push({
82
+ step: 'lint (clippy)',
83
+ cmd: 'cargo clippy --all-targets --all-features',
84
+ });
85
+ }
86
+ steps.push({ step: 'build', cmd: 'cargo build' });
87
+ steps.push({ step: 'test', cmd: 'cargo test' });
88
+ return steps;
89
+ }
90
+ // ============================================================================
91
+ // Go Discovery
92
+ // ============================================================================
93
+ /**
94
+ * Discover Go commands from go.mod presence
95
+ */
96
+ async function discoverGoCommands($, directory) {
97
+ if (!(await fileExists($, `${directory}/go.mod`)))
98
+ return [];
99
+ return [{ step: 'test', cmd: 'go test ./...' }];
100
+ }
101
+ // ============================================================================
102
+ // Python Discovery
103
+ // ============================================================================
104
+ /**
105
+ * Discover Python test commands with strong signal detection
106
+ */
107
+ async function discoverPythonCommands($, directory) {
108
+ // Only add pytest if we have strong signal
109
+ const hasPytestIni = await fileExists($, `${directory}/pytest.ini`);
110
+ const hasPyproject = await fileExists($, `${directory}/pyproject.toml`);
111
+ if (!hasPytestIni && !hasPyproject)
112
+ return [];
113
+ // pytest.ini is strong signal
114
+ if (hasPytestIni) {
115
+ return [{ step: 'test', cmd: 'pytest' }];
116
+ }
117
+ // Check if pyproject.toml mentions pytest
118
+ if (hasPyproject) {
119
+ try {
120
+ const result = await $ `cat ${directory}/pyproject.toml`.quiet();
121
+ const content = result.text();
122
+ if (content.includes('pytest')) {
123
+ return [{ step: 'test', cmd: 'pytest' }];
124
+ }
125
+ }
126
+ catch {
127
+ // Ignore read errors - return empty array if file can't be read
128
+ }
129
+ }
130
+ return [];
131
+ }
132
+ // ============================================================================
133
+ // Main Resolution Logic
134
+ // ============================================================================
135
+ /**
136
+ * Resolve the command plan based on configuration and discovery.
137
+ *
138
+ * Resolution order:
139
+ * 1. Configured commands (qualityGate.lint/build/test) - highest priority
140
+ * 2. management/ci.sh (Feature Factory convention) - only if no configured commands
141
+ * 3. Conventional discovery (Node/Rust/Go/Python) - only if no ci.sh
142
+ *
143
+ * @returns Array of command steps to execute, or empty if nothing found
144
+ */
145
+ export async function resolveCommands(args) {
146
+ const { $, directory, config } = args;
147
+ const mergedConfig = { ...DEFAULT_QUALITY_GATE, ...config };
148
+ // 1. Configured commands take priority (do NOT run ci.sh if these exist)
149
+ if (hasConfiguredCommands(config)) {
150
+ const steps = [];
151
+ const order = mergedConfig.steps;
152
+ for (const stepName of order) {
153
+ const cmd = config[stepName];
154
+ if (cmd) {
155
+ steps.push({ step: stepName, cmd });
156
+ }
157
+ }
158
+ return steps;
159
+ }
160
+ // 2. Feature Factory CI script (only if no configured commands)
161
+ if (mergedConfig.useCiSh !== 'never') {
162
+ const ciShPath = `${directory}/management/ci.sh`;
163
+ if (await fileExists($, ciShPath)) {
164
+ return [{ step: 'ci', cmd: `bash ${ciShPath}` }];
165
+ }
166
+ }
167
+ // 3. Conventional discovery (only if no ci.sh)
168
+ const pm = await detectPackageManager($, directory, mergedConfig.packageManager);
169
+ // Try Node first (most common)
170
+ if (await fileExists($, `${directory}/package.json`)) {
171
+ const nodeSteps = await discoverNodeCommands($, directory, pm);
172
+ if (nodeSteps.length > 0)
173
+ return nodeSteps;
174
+ }
175
+ // Rust
176
+ const rustSteps = await discoverRustCommands($, directory, mergedConfig.include?.rustClippy ?? true);
177
+ if (rustSteps.length > 0)
178
+ return rustSteps;
179
+ // Go
180
+ const goSteps = await discoverGoCommands($, directory);
181
+ if (goSteps.length > 0)
182
+ return goSteps;
183
+ // Python
184
+ const pythonSteps = await discoverPythonCommands($, directory);
185
+ if (pythonSteps.length > 0)
186
+ return pythonSteps;
187
+ // No commands discovered
188
+ return [];
189
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Unit tests for discovery module
3
+ *
4
+ * Tests focus on the pure function:
5
+ * - buildRunCommand: builds package manager-specific run commands
6
+ *
7
+ * Note: Most functions in discovery.ts require shell access ($) so they're
8
+ * integration-tested elsewhere. This file tests the pure utility functions.
9
+ */
10
+ export {};
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Unit tests for discovery module
3
+ *
4
+ * Tests focus on the pure function:
5
+ * - buildRunCommand: builds package manager-specific run commands
6
+ *
7
+ * Note: Most functions in discovery.ts require shell access ($) so they're
8
+ * integration-tested elsewhere. This file tests the pure utility functions.
9
+ */
10
+ // Re-implement buildRunCommand for testing since it's not exported
11
+ function buildRunCommand(pm, script) {
12
+ switch (pm) {
13
+ case 'pnpm':
14
+ return `pnpm -s run ${script}`;
15
+ case 'bun':
16
+ return `bun run ${script}`;
17
+ case 'yarn':
18
+ return `yarn -s ${script}`;
19
+ case 'npm':
20
+ return `npm run -s ${script}`;
21
+ }
22
+ }
23
+ describe('buildRunCommand', () => {
24
+ describe('pnpm', () => {
25
+ it('should build correct pnpm command', () => {
26
+ expect(buildRunCommand('pnpm', 'lint')).toBe('pnpm -s run lint');
27
+ });
28
+ it('should build correct pnpm command for build script', () => {
29
+ expect(buildRunCommand('pnpm', 'build')).toBe('pnpm -s run build');
30
+ });
31
+ it('should build correct pnpm command for test script', () => {
32
+ expect(buildRunCommand('pnpm', 'test')).toBe('pnpm -s run test');
33
+ });
34
+ it('should handle scripts with colons', () => {
35
+ expect(buildRunCommand('pnpm', 'lint:ci')).toBe('pnpm -s run lint:ci');
36
+ });
37
+ });
38
+ describe('bun', () => {
39
+ it('should build correct bun command', () => {
40
+ expect(buildRunCommand('bun', 'lint')).toBe('bun run lint');
41
+ });
42
+ it('should build correct bun command for build script', () => {
43
+ expect(buildRunCommand('bun', 'build')).toBe('bun run build');
44
+ });
45
+ it('should build correct bun command for test script', () => {
46
+ expect(buildRunCommand('bun', 'test')).toBe('bun run test');
47
+ });
48
+ });
49
+ describe('yarn', () => {
50
+ it('should build correct yarn command', () => {
51
+ expect(buildRunCommand('yarn', 'lint')).toBe('yarn -s lint');
52
+ });
53
+ it('should build correct yarn command for build script', () => {
54
+ expect(buildRunCommand('yarn', 'build')).toBe('yarn -s build');
55
+ });
56
+ it('should build correct yarn command for test script', () => {
57
+ expect(buildRunCommand('yarn', 'test')).toBe('yarn -s test');
58
+ });
59
+ });
60
+ describe('npm', () => {
61
+ it('should build correct npm command', () => {
62
+ expect(buildRunCommand('npm', 'lint')).toBe('npm run -s lint');
63
+ });
64
+ it('should build correct npm command for build script', () => {
65
+ expect(buildRunCommand('npm', 'build')).toBe('npm run -s build');
66
+ });
67
+ it('should build correct npm command for test script', () => {
68
+ expect(buildRunCommand('npm', 'test')).toBe('npm run -s test');
69
+ });
70
+ });
71
+ describe('edge cases', () => {
72
+ it('should handle script names with hyphens', () => {
73
+ expect(buildRunCommand('npm', 'type-check')).toBe('npm run -s type-check');
74
+ });
75
+ it('should handle script names with underscores', () => {
76
+ expect(buildRunCommand('pnpm', 'lint_fix')).toBe('pnpm -s run lint_fix');
77
+ });
78
+ it('should handle long script names', () => {
79
+ expect(buildRunCommand('yarn', 'test:unit:coverage')).toBe('yarn -s test:unit:coverage');
80
+ });
81
+ });
82
+ });
83
+ /**
84
+ * PackageManager type validation tests
85
+ * These ensure the type constraints are working correctly
86
+ */
87
+ describe('PackageManager type', () => {
88
+ it('should accept valid package manager values', () => {
89
+ const validManagers = ['pnpm', 'bun', 'yarn', 'npm'];
90
+ validManagers.forEach((pm) => {
91
+ expect(['pnpm', 'bun', 'yarn', 'npm']).toContain(pm);
92
+ });
93
+ });
94
+ });
95
+ export {};
@@ -0,0 +1,30 @@
1
+ import type { Plugin } from '@opencode-ai/plugin';
2
+ export declare const SKILL_PATHS: {
3
+ 'ff-mini-plan': string;
4
+ 'ff-todo-management': string;
5
+ 'ff-severity-classification': string;
6
+ 'ff-report-templates': string;
7
+ };
8
+ export declare const AGENT_PATHS: {
9
+ 'ff-acceptance': string;
10
+ 'ff-review': string;
11
+ 'ff-security': string;
12
+ 'ff-well-architected': string;
13
+ 'ff-validate': string;
14
+ };
15
+ /**
16
+ * Stop Quality Gate Plugin
17
+ *
18
+ * Runs quality gate checks when the agent becomes idle after editing files.
19
+ *
20
+ * Behavior:
21
+ * - Runs quality gate on session.idle (when agent finishes working)
22
+ * - Executes management/ci.sh directly (no LLM involvement)
23
+ * - On fast feedback: "Quality gate is running, please stand-by for results ..."
24
+ * - On success: "Quality gate passed"
25
+ * - On failure: passes full CI output to LLM for fix instructions
26
+ * - If management/ci.sh does not exist, quality gate does not run
27
+ */
28
+ export declare const StopQualityGatePlugin: Plugin;
29
+ export default StopQualityGatePlugin;
30
+ export type { QualityGateConfig, CommandStep, StepResult, SessionState, PackageManager, } from './types';
package/dist/index.js ADDED
@@ -0,0 +1,81 @@
1
+ import { createQualityGateHooks } from './stop-quality-gate';
2
+ // Export skill and agent paths for programmatic access
3
+ export const SKILL_PATHS = {
4
+ 'ff-mini-plan': './skills/ff-mini-plan/SKILL.md',
5
+ 'ff-todo-management': './skills/ff-todo-management/SKILL.md',
6
+ 'ff-severity-classification': './skills/ff-severity-classification/SKILL.md',
7
+ 'ff-report-templates': './skills/ff-report-templates/SKILL.md',
8
+ };
9
+ export const AGENT_PATHS = {
10
+ 'ff-acceptance': './agents/ff-acceptance.md',
11
+ 'ff-review': './agents/ff-review.md',
12
+ 'ff-security': './agents/ff-security.md',
13
+ 'ff-well-architected': './agents/ff-well-architected.md',
14
+ 'ff-validate': './agents/ff-validate.md',
15
+ };
16
+ const SERVICE_NAME = 'feature-factory';
17
+ /**
18
+ * Log a message using the OpenCode client's structured logging.
19
+ * Silently fails if logging is unavailable.
20
+ */
21
+ async function log(client, level, message, extra) {
22
+ try {
23
+ await client.app.log({
24
+ body: {
25
+ service: SERVICE_NAME,
26
+ level,
27
+ message,
28
+ extra,
29
+ },
30
+ });
31
+ }
32
+ catch {
33
+ // Logging failure should not affect plugin operation
34
+ }
35
+ }
36
+ /**
37
+ * Determine the project root directory.
38
+ * Prefer worktree (git root) if available, otherwise use directory.
39
+ */
40
+ function resolveRootDir(input) {
41
+ const worktree = (input.worktree ?? '').trim();
42
+ if (worktree.length > 0)
43
+ return worktree;
44
+ return input.directory;
45
+ }
46
+ /**
47
+ * Stop Quality Gate Plugin
48
+ *
49
+ * Runs quality gate checks when the agent becomes idle after editing files.
50
+ *
51
+ * Behavior:
52
+ * - Runs quality gate on session.idle (when agent finishes working)
53
+ * - Executes management/ci.sh directly (no LLM involvement)
54
+ * - On fast feedback: "Quality gate is running, please stand-by for results ..."
55
+ * - On success: "Quality gate passed"
56
+ * - On failure: passes full CI output to LLM for fix instructions
57
+ * - If management/ci.sh does not exist, quality gate does not run
58
+ */
59
+ export const StopQualityGatePlugin = async (input) => {
60
+ const { worktree, directory, client } = input;
61
+ const rootDir = resolveRootDir({ worktree, directory });
62
+ // Skip quality gate if no valid directory (e.g., global config with no project)
63
+ if (!rootDir || rootDir === '' || rootDir === '/') {
64
+ return {};
65
+ }
66
+ // Create quality gate hooks
67
+ let qualityGateHooks = {};
68
+ try {
69
+ qualityGateHooks = await createQualityGateHooks(input);
70
+ }
71
+ catch (error) {
72
+ await log(client, 'error', 'quality-gate.init-error', {
73
+ error: String(error),
74
+ });
75
+ }
76
+ return {
77
+ ...qualityGateHooks,
78
+ };
79
+ };
80
+ // Default export for OpenCode plugin discovery
81
+ export default StopQualityGatePlugin;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Extract lines that likely contain error information from command output.
3
+ * Includes 1-2 lines of context after each error line.
4
+ *
5
+ * @param output - The command output to search
6
+ * @param maxLines - Maximum number of error lines to extract
7
+ * @returns Array of error-like lines (up to maxLines)
8
+ */
9
+ export declare function extractErrorLines(output: string, maxLines: number): string[];
10
+ /**
11
+ * Return the last N lines of output, with a truncation notice if needed.
12
+ *
13
+ * @param output - The full command output
14
+ * @param maxLines - Maximum number of lines to return
15
+ * @returns Truncated output with notice, or full output if short enough
16
+ */
17
+ export declare function tailLines(output: string, maxLines: number): string;
package/dist/output.js ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Regex pattern for detecting error-like lines in command output
3
+ */
4
+ const ERROR_PATTERNS = /(error|failed|failure|panic|assert|exception|traceback|TypeError|ReferenceError|SyntaxError|FAILED|FAIL|ERR!)/i;
5
+ /**
6
+ * Extract lines that likely contain error information from command output.
7
+ * Includes 1-2 lines of context after each error line.
8
+ *
9
+ * @param output - The command output to search
10
+ * @param maxLines - Maximum number of error lines to extract
11
+ * @returns Array of error-like lines (up to maxLines)
12
+ */
13
+ export function extractErrorLines(output, maxLines) {
14
+ const lines = output.split('\n');
15
+ const errorLines = [];
16
+ for (let i = 0; i < lines.length && errorLines.length < maxLines; i++) {
17
+ const line = lines[i];
18
+ if (line && ERROR_PATTERNS.test(line)) {
19
+ errorLines.push(line);
20
+ // Include 1-2 lines of context after the error
21
+ const nextLine = lines[i + 1];
22
+ const nextNextLine = lines[i + 2];
23
+ if (nextLine !== undefined) {
24
+ errorLines.push(nextLine);
25
+ }
26
+ if (nextNextLine !== undefined) {
27
+ errorLines.push(nextNextLine);
28
+ }
29
+ }
30
+ }
31
+ return errorLines.slice(0, maxLines);
32
+ }
33
+ /**
34
+ * Return the last N lines of output, with a truncation notice if needed.
35
+ *
36
+ * @param output - The full command output
37
+ * @param maxLines - Maximum number of lines to return
38
+ * @returns Truncated output with notice, or full output if short enough
39
+ */
40
+ export function tailLines(output, maxLines) {
41
+ const lines = output.split('\n');
42
+ if (lines.length <= maxLines) {
43
+ return output;
44
+ }
45
+ const truncatedCount = lines.length - maxLines;
46
+ const tail = lines.slice(-maxLines).join('\n');
47
+ return `... (${truncatedCount} lines truncated)\n${tail}`;
48
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Unit tests for output module
3
+ *
4
+ * Tests focus on pure functions:
5
+ * - extractErrorLines: extracts error-like lines from command output
6
+ * - tailLines: returns last N lines with truncation notice
7
+ */
8
+ export {};