Skip to content

Commit e96780a

Browse files
committed
refactor(lint): split rules and keep service business-only
1 parent da13302 commit e96780a

15 files changed

Lines changed: 209 additions & 77 deletions

docs/ai/design/feature-lint-command.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ graph TD
2020
BaseCheck --> FS[(File System)]
2121
FeatureCheck --> FS
2222
GitCheck --> Git[(Git metadata)]
23-
LintService --> Reporter[Terminal and JSON reporter]
23+
CLI --> Reporter[Terminal and JSON reporter]
2424
```
2525

2626
- Key components and responsibilities
@@ -29,7 +29,7 @@ graph TD
2929
- Base docs checker: validate required phase template files (`docs/ai/*/README.md`).
3030
- Feature docs checker: validate `docs/ai/{phase}/feature-<name>.md` across lifecycle phases.
3131
- Git/worktree checker: evaluate feature branch/worktree convention used by `dev-lifecycle` Phase 1 prerequisite.
32-
- Reporter: consistent output rows (`[OK]`, `[MISS]`, `[WARN]`) and final summary with exit code.
32+
- Reporter (command-owned): consistent output rows (`[OK]`, `[MISS]`, `[WARN]`) and final summary with exit code.
3333
- Technology stack choices and rationale
3434
- TypeScript within existing CLI package for shared UX and testability.
3535
- Extract shell script checks into reusable TS utilities to avoid behavior drift.

docs/ai/implementation/feature-lint-command.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,13 @@ description: Technical implementation notes, patterns, and code guidelines
4343
- Missing git repository => required failure.
4444
- Missing `feature-<name>` branch => required failure.
4545
- Missing dedicated worktree for branch => warning only.
46-
- Reporter supports:
46+
- Command rendering supports:
4747
- human-readable checklist output
4848
- JSON report output with summary and per-check metadata
4949

5050
### Patterns & Best Practices
5151
- `runLintChecks` accepts injected dependencies (`cwd`, `existsSync`, `execFileSync`) for testability.
52+
- Shared phase-doc rule helper keeps base/feature doc checks consistent while avoiding duplication.
5253
- Check results use a normalized shape (`LintCheckResult`) so rendering and JSON use one source of truth.
5354
- Required failures drive exit code; warnings are advisory only.
5455

docs/ai/testing/feature-lint-command.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ description: Define testing approach, test cases, and quality assurance
2323
- [x] Branch exists + no dedicated worktree returns pass with warning.
2424
- [x] Missing feature branch returns required failure.
2525
- [x] Non-git directory in feature mode returns required failure.
26+
- [x] Rule-level suites cover base-docs, feature-name, and git-worktree rule behavior directly.
2627

2728
## Integration Tests
2829
**How do we test component interactions?**
@@ -48,7 +49,7 @@ description: Define testing approach, test cases, and quality assurance
4849
**How do we verify and communicate test results?**
4950

5051
- Executed:
51-
- `nx run cli:test -- --runInBand lint.test.ts` (pass, 2 suites / 9 tests)
52+
- `nx run cli:test -- --runInBand lint.test.ts` (pass, 5 suites / 16 tests)
5253
- `npm run lint` in `packages/cli` (pass with pre-existing repo warnings unrelated to lint command)
5354
- Manual verification runs:
5455
- Current repo + `feature=lint-command`: pass `true`, exit code `0`, zero required failures.

packages/cli/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,15 @@ ai-devkit init --template ./ai-devkit.init.yaml
4949
# Add a development phase
5050
ai-devkit phase requirements
5151

52+
# Validate workspace docs readiness
53+
ai-devkit lint
54+
55+
# Validate a feature's docs and git branch/worktree conventions
56+
ai-devkit lint --feature lint-command
57+
58+
# Emit machine-readable output for CI
59+
ai-devkit lint --feature lint-command --json
60+
5261
# Install a skill
5362
ai-devkit skill add <skill-registry> <skill-name>
5463

packages/cli/src/__tests__/services/lint/lint.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, expect, it } from '@jest/globals';
22
import { normalizeFeatureName, runLintChecks } from '../../../services/lint/lint.service';
33

4-
describe('lint utilities', () => {
4+
describe('lint service', () => {
55
it('normalizes feature names with optional feature- prefix', () => {
66
expect(normalizeFeatureName('lint-command')).toBe('lint-command');
77
expect(normalizeFeatureName('feature-lint-command')).toBe('lint-command');
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { describe, expect, it } from '@jest/globals';
2+
import { runBaseDocsRules } from '../../../../services/lint/rules/base-docs.rule';
3+
import { LintDependencies } from '../../../../services/lint/types';
4+
5+
describe('base docs rule', () => {
6+
it('returns ok checks when all base docs exist', () => {
7+
const deps: LintDependencies = {
8+
cwd: () => '/repo',
9+
existsSync: () => true,
10+
execFileSync: () => ''
11+
};
12+
13+
const checks = runBaseDocsRules('/repo', deps);
14+
15+
expect(checks).toHaveLength(5);
16+
expect(checks.every(check => check.level === 'ok')).toBe(true);
17+
});
18+
19+
it('returns missing checks when base docs do not exist', () => {
20+
const deps: LintDependencies = {
21+
cwd: () => '/repo',
22+
existsSync: () => false,
23+
execFileSync: () => ''
24+
};
25+
26+
const checks = runBaseDocsRules('/repo', deps);
27+
28+
expect(checks).toHaveLength(5);
29+
expect(checks.every(check => check.level === 'miss')).toBe(true);
30+
expect(checks[0].fix).toBe('Run: npx ai-devkit init');
31+
});
32+
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { describe, expect, it } from '@jest/globals';
2+
import { normalizeFeatureName, validateFeatureNameRule } from '../../../../services/lint/rules/feature-name.rule';
3+
4+
describe('feature name rule', () => {
5+
it('normalizes optional feature- prefix', () => {
6+
expect(normalizeFeatureName('feature-lint-command')).toBe('lint-command');
7+
expect(normalizeFeatureName('lint-command')).toBe('lint-command');
8+
});
9+
10+
it('returns no validation check for valid names', () => {
11+
const result = validateFeatureNameRule('feature-lint-command');
12+
13+
expect(result.check).toBeUndefined();
14+
expect(result.target.branchName).toBe('feature-lint-command');
15+
});
16+
17+
it('returns missing check for invalid names', () => {
18+
const result = validateFeatureNameRule('lint command');
19+
20+
expect(result.check?.id).toBe('feature-name');
21+
expect(result.check?.level).toBe('miss');
22+
});
23+
});
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { describe, expect, it } from '@jest/globals';
2+
import { runGitWorktreeRules } from '../../../../services/lint/rules/git-worktree.rule';
3+
import { LintDependencies } from '../../../../services/lint/types';
4+
5+
describe('git worktree rule', () => {
6+
it('returns required failure when not in git repo', () => {
7+
const deps: LintDependencies = {
8+
cwd: () => '/repo',
9+
existsSync: () => true,
10+
execFileSync: (_file: string, args: readonly string[]) => {
11+
if (args[0] === 'rev-parse') {
12+
throw new Error('not git');
13+
}
14+
return '';
15+
}
16+
};
17+
18+
const checks = runGitWorktreeRules('/repo', 'feature-sample', deps);
19+
20+
expect(checks).toHaveLength(1);
21+
expect(checks[0].id).toBe('git-repo');
22+
expect(checks[0].required).toBe(true);
23+
});
24+
25+
it('returns warning when branch exists and dedicated worktree is missing', () => {
26+
const deps: LintDependencies = {
27+
cwd: () => '/repo',
28+
existsSync: () => true,
29+
execFileSync: (_file: string, args: readonly string[]) => {
30+
const cmd = args.join(' ');
31+
if (cmd.startsWith('rev-parse')) {
32+
return 'true\n';
33+
}
34+
if (cmd.startsWith('show-ref')) {
35+
return '';
36+
}
37+
if (cmd.startsWith('worktree list --porcelain')) {
38+
return 'worktree /repo\nbranch refs/heads/main\n\n';
39+
}
40+
return '';
41+
}
42+
};
43+
44+
const checks = runGitWorktreeRules('/repo', 'feature-sample', deps);
45+
46+
expect(checks[0].id).toBe('git-branch');
47+
expect(checks[1].id).toBe('git-worktree');
48+
expect(checks[1].level).toBe('warn');
49+
});
50+
});

packages/cli/src/commands/lint.ts

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,24 @@ export function renderLintReport(report: LintReport, options: LintOptions = {}):
1313
return;
1414
}
1515

16-
printCategory('base-docs', report);
16+
const sections: Array<{ title: string; category: LintCheckResult['category'] }> = [
17+
{ title: '=== Base Structure ===', category: 'base-docs' }
18+
];
1719

1820
if (report.feature) {
19-
ui.text('');
20-
ui.text(`=== Feature: ${report.feature.normalizedName} ===`);
21-
printRows(report.checks.filter(check => check.category === 'feature-docs'));
22-
23-
ui.text('');
24-
ui.text(`=== Git: ${report.feature.branchName} ===`);
25-
printRows(report.checks.filter(check => check.category === 'git-worktree'));
21+
sections.push(
22+
{ title: `=== Feature: ${report.feature.normalizedName} ===`, category: 'feature-docs' },
23+
{ title: `=== Git: ${report.feature.branchName} ===`, category: 'git-worktree' }
24+
);
2625
}
2726

27+
sections.forEach((section, index) => {
28+
if (index > 0) {
29+
ui.text('');
30+
}
31+
printSection(section.title, section.category, report);
32+
});
33+
2834
ui.text('');
2935
if (report.pass) {
3036
ui.text('All checks passed.');
@@ -37,11 +43,8 @@ export function renderLintReport(report: LintReport, options: LintOptions = {}):
3743
}
3844
}
3945

40-
function printCategory(category: LintCheckResult['category'], report: LintReport): void {
41-
if (category === 'base-docs') {
42-
ui.text('=== Base Structure ===');
43-
}
44-
46+
function printSection(title: string, category: LintCheckResult['category'], report: LintReport): void {
47+
ui.text(title);
4548
printRows(report.checks.filter(check => check.category === category));
4649
}
4750

packages/cli/src/services/lint/lint.service.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { runBaseDocsRules } from './rules/base-docs.rule';
44
import { runFeatureDocsRules } from './rules/feature-docs.rule';
55
import { validateFeatureNameRule, normalizeFeatureName } from './rules/feature-name.rule';
66
import { runGitWorktreeRules } from './rules/git-worktree.rule';
7-
import { LintCheckResult, LintDependencies, LintOptions, LintReport } from './types';
7+
import { FeatureTarget, LintCheckResult, LintDependencies, LintOptions, LintReport } from './types';
88

99
const defaultDependencies: LintDependencies = {
1010
cwd: () => process.cwd(),
@@ -34,7 +34,16 @@ export function runLintChecks(
3434
return finalizeReport(cwd, checks);
3535
}
3636

37-
const featureValidation = validateFeatureNameRule(options.feature);
37+
return runFeatureChecks(cwd, checks, options.feature, deps);
38+
}
39+
40+
function runFeatureChecks(
41+
cwd: string,
42+
checks: LintCheckResult[],
43+
rawFeature: string,
44+
deps: LintDependencies
45+
): LintReport {
46+
const featureValidation = validateFeatureNameRule(rawFeature);
3847
if (featureValidation.check) {
3948
checks.push(featureValidation.check);
4049
return finalizeReport(cwd, checks, featureValidation.target);
@@ -49,7 +58,7 @@ export function runLintChecks(
4958
function finalizeReport(
5059
cwd: string,
5160
checks: LintCheckResult[],
52-
feature?: { raw: string; normalizedName: string; branchName: string }
61+
feature?: FeatureTarget
5362
): LintReport {
5463
const summary = checks.reduce(
5564
(acc, check) => {

0 commit comments

Comments
 (0)