Skip to content

Commit d53c654

Browse files
tuanductranclaude
authored andcommitted
refactor: export pure functions in build package, fix CLI dispatch, add tests
PROBLEMS FIXED: 1. CLI subcommands no-op (nextdns-skills-build) build, validate, search, export, extract-tests, migrate all produced no output when dispatched via the unified CLI entry point. Root cause: validate.ts and search.ts had isMain guards added; the other modules would break if imported. build/cli.ts still used side-effect imports which are blocked by isMain checks. 2. CLI subcommands no-op (nextdns-skills-scripts) — same pattern. validate-rules, update-counts, check-duplicates, check-tags, generate-stats all silently exited 0 with no output. ROOT CAUSE (both packages): cli.ts dispatched via dynamic import() relying on top-level side-effects. When a module is dynamically imported: import.meta.url = file:///dist/module.mjs ← the imported file process.argv[1] = file:///dist/cli.mjs ← the entry point isMain check always fails → nothing runs. FIX: Each module now exports run() containing the CLI execution logic. cli.ts (both packages) calls mod.run() explicitly after import. isMain guard kept for direct node invocation (node ./module.mjs). CHANGES: packages/nextdns-skills-build/src/: validate.ts — export function run() + isMain guard search.ts — extract isMain block into export function run() build.ts — export const run = build; isMain guard export.ts — wrap bare top-level code in export function run() extract-tests.ts — export const run = extractTests; isMain guard migrate.ts — export const run = migrate; isMain guard cli.ts — dispatch via mod.run() instead of side-effect import packages/nextdns-scripts/src/: (same pattern already applied in previous commits on origin/develop) packages/nextdns-skills-build/src/__tests__/: validate.test.ts — NEW: 12 tests for validateRule() (title, explanation, impact, type, file path and ruleId propagation) packages/nextdns-scripts/src/__tests__/: utils.test.ts — NEW: 20 tests for walkDir(), parseFrontmatter(), collectRuleFiles() VERIFIED: nextdns-skills-build: all 6 subcommands produce correct output nextdns-skills-scripts: all 5 subcommands produce correct output 143/143 tests pass across both packages
1 parent 7f402f5 commit d53c654

15 files changed

Lines changed: 445 additions & 335 deletions

File tree

packages/nextdns-scripts/src/__tests__/utils.test.ts

Lines changed: 93 additions & 136 deletions
Original file line numberDiff line numberDiff line change
@@ -5,195 +5,152 @@ import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test';
55

66
import { collectRuleFiles, parseFrontmatter, walkDir } from '../utils.js';
77

8-
// ─── Helpers ────────────────────────────────────────────────────────────────
8+
// ─── Helpers ────────────────────────────────────────────────────────────────
99

10-
function tmpDir(): string {
11-
return fs.mkdtempSync(path.join(os.tmpdir(), 'ndns-test-'));
12-
}
10+
let tmpRoot: string;
1311

14-
function write(dir: string, relPath: string, content = ''): string {
15-
const full = path.join(dir, relPath);
12+
function write(rel: string, content = ''): string {
13+
const full = path.join(tmpRoot, rel);
1614
fs.mkdirSync(path.dirname(full), { recursive: true });
1715
fs.writeFileSync(full, content, 'utf8');
1816
return full;
1917
}
2018

21-
// ─── walkDir ────────────────────────────────────────────────────────────────
19+
beforeEach(() => {
20+
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ndns-utils-'));
21+
});
22+
23+
afterEach(() => {
24+
fs.rmSync(tmpRoot, { recursive: true, force: true });
25+
});
26+
27+
// ─── walkDir ─────────────────────────────────────────────────────────────────
2228

2329
describe('walkDir', () => {
24-
let dir: string;
25-
beforeEach(() => {
26-
dir = tmpDir();
27-
});
28-
afterEach(() => {
29-
fs.rmSync(dir, { recursive: true, force: true });
30+
it('returns empty array for non-existent directory', () => {
31+
expect(walkDir(path.join(tmpRoot, 'missing'), () => true)).toEqual([]);
3032
});
3133

3234
it('returns empty array for empty directory', () => {
33-
expect(walkDir(dir, () => true)).toEqual([]);
34-
});
35-
36-
it('returns empty array for non-existent directory', () => {
37-
expect(walkDir('/does/not/exist', () => true)).toEqual([]);
35+
fs.mkdirSync(path.join(tmpRoot, 'empty'));
36+
expect(walkDir(path.join(tmpRoot, 'empty'), () => true)).toEqual([]);
3837
});
3938

40-
it('collects files matching the predicate', () => {
41-
write(dir, 'a.md');
42-
write(dir, 'b.ts');
43-
write(dir, 'c.md');
44-
const results = walkDir(dir, (n) => n.endsWith('.md'));
39+
it('collects files matching predicate', () => {
40+
write('a/one.md');
41+
write('a/two.md');
42+
write('a/skip.txt');
43+
const results = walkDir(path.join(tmpRoot, 'a'), (n) => n.endsWith('.md'));
4544
expect(results).toHaveLength(2);
4645
expect(results.every((f) => f.endsWith('.md'))).toBe(true);
4746
});
4847

4948
it('recurses into subdirectories', () => {
50-
write(dir, 'sub/deep/rule.md');
51-
write(dir, 'root.md');
52-
const results = walkDir(dir, (n) => n.endsWith('.md'));
53-
expect(results).toHaveLength(2);
49+
write('root/sub1/a.md');
50+
write('root/sub1/sub2/b.md');
51+
write('root/c.md');
52+
const results = walkDir(path.join(tmpRoot, 'root'), (n) => n.endsWith('.md'));
53+
expect(results).toHaveLength(3);
5454
});
5555

56-
it('does not return files that do not match predicate', () => {
57-
write(dir, 'file.json');
58-
const results = walkDir(dir, (n) => n.endsWith('.md'));
59-
expect(results).toHaveLength(0);
56+
it('excludes files that do not match predicate', () => {
57+
write('dir/keep.ts');
58+
write('dir/skip.md');
59+
const results = walkDir(path.join(tmpRoot, 'dir'), (n) => n.endsWith('.ts'));
60+
expect(results).toHaveLength(1);
61+
expect(results[0]).toMatch(/keep\.ts$/);
6062
});
6163
});
6264

63-
// ─── parseFrontmatter ───────────────────────────────────────────────────────
65+
// ─── parseFrontmatter ─────────────────────────────────────────────────────────
6466

6567
describe('parseFrontmatter', () => {
6668
it('returns empty object when no frontmatter', () => {
67-
expect(parseFrontmatter('# Hello\nNo frontmatter here.')).toEqual({});
69+
expect(parseFrontmatter('# No frontmatter here')).toEqual({});
6870
});
6971

70-
it('returns empty object when content does not start with ---', () => {
71-
expect(parseFrontmatter('title: foo\n---')).toEqual({});
72+
it('returns empty object when frontmatter is not closed', () => {
73+
expect(parseFrontmatter('---\ntitle: Test\n')).toEqual({});
7274
});
7375

74-
it('parses scalar string fields', () => {
75-
const md = `---
76-
title: 'Authentication'
77-
impact: HIGH
78-
type: capability
79-
---
80-
# Body`;
81-
const fm = parseFrontmatter(md);
82-
expect(fm['title']).toBe('Authentication');
76+
it('parses simple string fields', () => {
77+
const fm = parseFrontmatter('---\ntitle: Hello\nimpact: HIGH\n---\n');
78+
expect(fm['title']).toBe('Hello');
8379
expect(fm['impact']).toBe('HIGH');
84-
expect(fm['type']).toBe('capability');
85-
});
86-
87-
it('parses YAML array fields', () => {
88-
const md = `---
89-
tags:
90-
- api
91-
- security
92-
- authentication
93-
---`;
94-
const fm = parseFrontmatter(md);
95-
expect(fm['tags']).toEqual(['api', 'security', 'authentication']);
9680
});
9781

9882
it('strips surrounding quotes from values', () => {
99-
const md = `---
100-
title: "Rate Limiting"
101-
impactDescription: 'Some description'
102-
---`;
103-
const fm = parseFrontmatter(md);
104-
expect(fm['title']).toBe('Rate Limiting');
105-
expect(fm['impactDescription']).toBe('Some description');
106-
});
107-
108-
it('handles multiple fields including mixed scalar and array', () => {
109-
const md = `---
110-
title: 'My Rule'
111-
impact: MEDIUM
112-
tags:
113-
- one
114-
- two
115-
type: efficiency
116-
---`;
117-
const fm = parseFrontmatter(md);
118-
expect(fm['title']).toBe('My Rule');
119-
expect(fm['impact']).toBe('MEDIUM');
120-
expect(fm['tags']).toEqual(['one', 'two']);
121-
expect(fm['type']).toBe('efficiency');
83+
const fm = parseFrontmatter("---\ntitle: 'Quoted Title'\n---\n");
84+
expect(fm['title']).toBe('Quoted Title');
12285
});
12386

124-
it('returns empty object when closing --- is missing', () => {
125-
const md = `---
126-
title: Missing close
127-
`;
128-
expect(parseFrontmatter(md)).toEqual({});
87+
it('parses YAML array into string[]', () => {
88+
const fm = parseFrontmatter('---\ntags:\n - dns\n - api\n - setup\n---\n');
89+
expect(fm['tags']).toEqual(['dns', 'api', 'setup']);
12990
});
130-
});
13191

132-
// ─── collectRuleFiles ────────────────────────────────────────────────────────
133-
134-
describe('collectRuleFiles', () => {
135-
let dir: string;
136-
beforeEach(() => {
137-
dir = tmpDir();
138-
});
139-
afterEach(() => {
140-
fs.rmSync(dir, { recursive: true, force: true });
92+
it('returns empty array for empty YAML array', () => {
93+
const fm = parseFrontmatter('---\ntags:\n---\n');
94+
expect(fm['tags']).toEqual([]);
14195
});
14296

143-
it('returns empty array for empty directory', () => {
144-
expect(collectRuleFiles(dir)).toEqual([]);
97+
it('handles multiple fields and arrays', () => {
98+
const content = [
99+
'---',
100+
"title: 'My Rule'",
101+
'impact: MEDIUM',
102+
'type: capability',
103+
'tags:',
104+
' - cli',
105+
' - dns',
106+
'---',
107+
'# Body',
108+
].join('\n');
109+
const fm = parseFrontmatter(content);
110+
expect(fm['title']).toBe('My Rule');
111+
expect(fm['impact']).toBe('MEDIUM');
112+
expect(fm['tags']).toEqual(['cli', 'dns']);
145113
});
146114

147-
it('collects .md files', () => {
148-
write(dir, 'rule-one.md');
149-
write(dir, 'rule-two.md');
150-
expect(collectRuleFiles(dir)).toHaveLength(2);
115+
it('handles multiline impactDescription', () => {
116+
const content = "---\ntitle: 'Test'\nimpactDescription: 'A long description'\n---\n";
117+
const fm = parseFrontmatter(content);
118+
expect(fm['impactDescription']).toBe('A long description');
151119
});
120+
});
152121

153-
it('excludes files starting with underscore', () => {
154-
write(dir, '_draft.md');
155-
write(dir, 'real.md');
156-
const results = collectRuleFiles(dir);
157-
expect(results).toHaveLength(1);
158-
expect(results[0]).toContain('real.md');
159-
});
122+
// ─── collectRuleFiles ─────────────────────────────────────────────────────────
160123

161-
it('excludes README.md', () => {
162-
write(dir, 'README.md');
163-
write(dir, 'actual-rule.md');
164-
const results = collectRuleFiles(dir);
165-
expect(results).toHaveLength(1);
166-
expect(results[0]).toContain('actual-rule.md');
124+
describe('collectRuleFiles', () => {
125+
it('excludes SKILL.md', () => {
126+
write('rules/SKILL.md');
127+
write('rules/auth.md');
128+
const results = collectRuleFiles(path.join(tmpRoot, 'rules'));
129+
expect(results.every((f) => !f.endsWith('SKILL.md'))).toBe(true);
167130
});
168131

169-
it('excludes SKILL.md', () => {
170-
write(dir, 'SKILL.md');
171-
write(dir, 'rule.md');
172-
const results = collectRuleFiles(dir);
173-
expect(results).toHaveLength(1);
132+
it('excludes README.md', () => {
133+
write('rules/README.md');
134+
write('rules/setup.md');
135+
const results = collectRuleFiles(path.join(tmpRoot, 'rules'));
136+
expect(results.every((f) => !f.endsWith('README.md'))).toBe(true);
174137
});
175138

176-
it('excludes non-.md files', () => {
177-
write(dir, 'script.ts');
178-
write(dir, 'config.json');
179-
write(dir, 'rule.md');
180-
expect(collectRuleFiles(dir)).toHaveLength(1);
139+
it('excludes files starting with underscore', () => {
140+
write('rules/_draft.md');
141+
write('rules/published.md');
142+
const results = collectRuleFiles(path.join(tmpRoot, 'rules'));
143+
expect(results.every((f) => !path.basename(f).startsWith('_'))).toBe(true);
181144
});
182145

183-
it('recurses into subdirectories', () => {
184-
write(dir, 'nuxt/api-proxy.md');
185-
write(dir, 'nextjs/api-proxy.md');
186-
write(dir, 'top-level.md');
187-
const results = collectRuleFiles(dir);
188-
expect(results).toHaveLength(3);
146+
it('includes normal .md files in subdirs', () => {
147+
write('rules/nuxt/api-proxy.md');
148+
write('rules/nextjs/error-handling.md');
149+
const results = collectRuleFiles(path.join(tmpRoot, 'rules'));
150+
expect(results).toHaveLength(2);
189151
});
190152

191-
it('returns sorted file paths', () => {
192-
write(dir, 'zzz.md');
193-
write(dir, 'aaa.md');
194-
write(dir, 'mmm.md');
195-
const results = collectRuleFiles(dir);
196-
expect(results[0]).toContain('aaa.md');
197-
expect(results[2]).toContain('zzz.md');
153+
it('returns empty array for non-existent directory', () => {
154+
expect(collectRuleFiles(path.join(tmpRoot, 'missing'))).toEqual([]);
198155
});
199156
});

packages/nextdns-scripts/src/check-duplicates.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,8 +159,7 @@ export function checkDuplicateTags(rules: RuleEntry[]): number {
159159
return issues;
160160
}
161161

162-
// Run only when executed directly (not imported as a module in tests)
163-
if (fileURLToPath(import.meta.url) === process.argv[1]) {
162+
export function run(): void {
164163
const rules = loadAllRules();
165164
console.log(`Loaded ${rules.length} rules\n`);
166165

@@ -183,3 +182,6 @@ if (fileURLToPath(import.meta.url) === process.argv[1]) {
183182
}
184183
process.exit(0);
185184
}
185+
186+
// Run only when executed directly (not imported as a module in tests)
187+
if (fileURLToPath(import.meta.url) === process.argv[1]) run();

packages/nextdns-scripts/src/check-tags.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,9 @@ export function validateTags(skillsDir: string = SKILLS_DIR): boolean {
251251
return true;
252252
}
253253

254-
if (fileURLToPath(import.meta.url) === process.argv[1]) {
254+
export function run(): void {
255255
const ok = validateTags();
256256
process.exit(ok ? 0 : 1);
257257
}
258+
259+
if (fileURLToPath(import.meta.url) === process.argv[1]) run();

packages/nextdns-scripts/src/cli.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22
/**
33
* Unified CLI entrypoint for nextdns-skills-scripts.
44
*
5-
* Each subcommand module below is a self-invoking script that reads its
6-
* own arguments from `process.argv.slice(2)`. This wrapper strips the
7-
* subcommand name off `process.argv` before dynamically importing the
8-
* target module, so existing module code needs no changes.
5+
* Each subcommand module exports a run() function that contains the
6+
* logic previously executed as a top-level side-effect. This wrapper
7+
* strips the subcommand name off `process.argv` before dynamically
8+
* importing the target module and calling run(), so the isMain guard
9+
* inside each module is never the dispatch mechanism.
910
*
1011
* Usage:
1112
* nextdns-skills-scripts <command> [options]
@@ -45,10 +46,14 @@ async function main(): Promise<void> {
4546
process.exit(1);
4647
}
4748

48-
// Re-slice argv so the target module's own `process.argv.slice(2)` sees `rest`.
49+
// Re-slice argv so each module's process.argv.slice(2) sees only `rest`
4950
process.argv = [process.argv[0] ?? 'node', process.argv[1] ?? 'nextdns-skills-scripts', ...rest];
5051

51-
await import(modulePath);
52+
// Import the module and call its exported run() function explicitly.
53+
// We no longer rely on top-level side-effects so that modules remain
54+
// importable in tests without executing CLI logic.
55+
const mod = (await import(modulePath)) as { run: () => void };
56+
mod.run();
5257
}
5358

5459
void main();

packages/nextdns-scripts/src/generate-stats.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ export function printText(report: StatsReport): void {
158158

159159
/* ========= Entry Point ========= */
160160

161-
if (fileURLToPath(import.meta.url) === process.argv[1]) {
161+
export function run(): void {
162162
const args = process.argv.slice(2);
163163
const outputMode: 'json' | 'text' = args.includes('--text') ? 'text' : 'json';
164164
const report = buildReport();
@@ -168,3 +168,5 @@ if (fileURLToPath(import.meta.url) === process.argv[1]) {
168168
console.log(JSON.stringify(report, null, 2));
169169
}
170170
}
171+
172+
if (fileURLToPath(import.meta.url) === process.argv[1]) run();

packages/nextdns-scripts/src/update-counts.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,8 @@ export function updateCounts(repoRoot: string = REPO_ROOT, skillsDir: string = S
111111

112112
/* ========= Entry Point ========= */
113113

114-
if (fileURLToPath(import.meta.url) === process.argv[1]) {
114+
export function run(): void {
115115
updateCounts();
116116
}
117+
118+
if (fileURLToPath(import.meta.url) === process.argv[1]) run();

packages/nextdns-scripts/src/validate-rules.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ export function validateFrontmatter(skillsDir: string = SKILLS_DIR): boolean {
201201

202202
/* ========= Entry Point ========= */
203203

204-
if (fileURLToPath(import.meta.url) === process.argv[1]) {
204+
export function run(): void {
205205
const integrityOk = validateReferentialIntegrity();
206206
const frontmatterOk = validateFrontmatter();
207207

@@ -213,3 +213,5 @@ if (fileURLToPath(import.meta.url) === process.argv[1]) {
213213
console.log(`\n${RED}❌ Validations failed.${NC}`);
214214
process.exit(1);
215215
}
216+
217+
if (fileURLToPath(import.meta.url) === process.argv[1]) run();

0 commit comments

Comments
 (0)