// tools/graphify-safe-update.test.mjs // Pure-helper tests for the post-commit safe-update wrapper. // Covers scope filtering + code/doc partitioning — the parts that gated // the spike-bloat incident (ADR-017 § "Стратегия обновлений"). import { describe, it, expect } from 'vitest'; import { filterInScope, partitionByExtension } from './graphify-safe-update.mjs'; const ALLOWED_SCOPES = ['docs/', '.claude/', 'app/']; const SCAN_EXCLUDE_DIRS = ['node_modules/', 'vendor/', '__pycache__/', '.git/']; const CODE_EXTS = new Set(['.php', '.ts', '.js', '.vue', '.mjs', '.cjs', '.py', '.go']); describe('filterInScope', () => { it('keeps files inside allowed scopes', () => { const input = ['docs/foo.md', '.claude/skills/x/SKILL.md', 'app/Models/Deal.php']; expect(filterInScope(input, ALLOWED_SCOPES, SCAN_EXCLUDE_DIRS)).toEqual(input); }); it('rejects files outside allowed scopes (tools/, root .md, bin/)', () => { const input = [ 'tools/graphify-safe-update.mjs', 'CHANGELOG.md', 'bin/something.exe', 'package.json', 'README.md', ]; expect(filterInScope(input, ALLOWED_SCOPES, SCAN_EXCLUDE_DIRS)).toEqual([]); }); it('rejects vendor/node_modules even if nominally under app/', () => { const input = [ 'app/vendor/laravel/framework/Foo.php', 'app/node_modules/foo/bar.js', 'app/__pycache__/x.pyc', 'app/.git/HEAD', ]; expect(filterInScope(input, ALLOWED_SCOPES, SCAN_EXCLUDE_DIRS)).toEqual([]); }); it('keeps real app/ files alongside vendor exclusions', () => { const input = [ 'app/app/Services/LedgerService.php', 'app/vendor/laravel/framework/Container.php', 'app/resources/js/views/Dashboard.vue', ]; expect(filterInScope(input, ALLOWED_SCOPES, SCAN_EXCLUDE_DIRS)).toEqual([ 'app/app/Services/LedgerService.php', 'app/resources/js/views/Dashboard.vue', ]); }); it('returns empty array on empty input', () => { expect(filterInScope([], ALLOWED_SCOPES, SCAN_EXCLUDE_DIRS)).toEqual([]); }); it('handles mixed in-scope and out-of-scope', () => { const input = [ 'docs/CLAUDE.md', 'tools/x.mjs', 'app/Models/Deal.php', 'CHANGELOG.md', '.claude/skills/y/SKILL.md', ]; expect(filterInScope(input, ALLOWED_SCOPES, SCAN_EXCLUDE_DIRS)).toEqual([ 'docs/CLAUDE.md', 'app/Models/Deal.php', '.claude/skills/y/SKILL.md', ]); }); }); describe('partitionByExtension', () => { it('puts .php/.ts/.vue/.mjs into code', () => { const input = ['app/Foo.php', 'app/bar.ts', 'app/baz.vue', 'app/qux.mjs']; const { codeFiles, docFiles } = partitionByExtension(input, CODE_EXTS); expect(codeFiles).toEqual(input); expect(docFiles).toEqual([]); }); it('puts .md/.txt into doc', () => { const input = ['docs/foo.md', '.claude/bar.md', 'app/README.txt']; const { codeFiles, docFiles } = partitionByExtension(input, CODE_EXTS); expect(codeFiles).toEqual([]); expect(docFiles).toEqual(input); }); it('partitions mixed list correctly', () => { const input = [ 'app/Models/Deal.php', 'docs/spec.md', 'app/resources/js/utils.ts', '.claude/skills/x/SKILL.md', 'app/composer.json', ]; const { codeFiles, docFiles } = partitionByExtension(input, CODE_EXTS); expect(codeFiles).toEqual(['app/Models/Deal.php', 'app/resources/js/utils.ts']); expect(docFiles).toEqual([ 'docs/spec.md', '.claude/skills/x/SKILL.md', 'app/composer.json', ]); }); it('is case-insensitive on extension', () => { const input = ['app/Foo.PHP', 'app/Bar.TS', 'docs/README.MD']; const { codeFiles, docFiles } = partitionByExtension(input, CODE_EXTS); expect(codeFiles).toEqual(['app/Foo.PHP', 'app/Bar.TS']); expect(docFiles).toEqual(['docs/README.MD']); }); it('returns empty arrays on empty input', () => { const { codeFiles, docFiles } = partitionByExtension([], CODE_EXTS); expect(codeFiles).toEqual([]); expect(docFiles).toEqual([]); }); });