prjct-cli 1.13.0 → 1.15.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/CHANGELOG.md +90 -0
- package/core/__tests__/agentic/semantic-matching.test.ts +131 -0
- package/core/__tests__/agentic/tech-normalizer.test.ts +136 -0
- package/core/__tests__/domain/velocity.test.ts +623 -0
- package/core/agentic/anti-hallucination.ts +11 -16
- package/core/agentic/memory-system.ts +224 -23
- package/core/agentic/orchestrator-executor.ts +25 -2
- package/core/agentic/prompt-builder.ts +16 -1
- package/core/agentic/tech-normalizer.ts +167 -0
- package/core/commands/command-data.ts +17 -0
- package/core/commands/commands.ts +12 -0
- package/core/commands/register.ts +6 -0
- package/core/commands/velocity.ts +149 -0
- package/core/domain/velocity.ts +470 -0
- package/core/index.ts +1 -0
- package/core/schemas/index.ts +2 -0
- package/core/schemas/velocity.ts +103 -0
- package/core/storage/index.ts +2 -1
- package/core/storage/velocity-storage.ts +149 -0
- package/core/types/agentic.ts +2 -0
- package/core/types/memory.ts +19 -9
- package/dist/bin/prjct.mjs +1295 -433
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,95 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.15.0] - 2026-02-09
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- replace hardcoded memory domain tags with semantic matching (PRJ-300) (#157)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
## [1.14.1] - 2026-02-09
|
|
11
|
+
|
|
12
|
+
### Improved
|
|
13
|
+
- **Semantic memory domain matching** (PRJ-300): Memory retrieval now uses two-pass scoring (exact MEMORY_TAG match + semantic keyword match) instead of exact-only tag matching. Unknown domains like "uxui" are resolved to canonical domains ("frontend") via SEMANTIC_DOMAIN_KEYWORDS map.
|
|
14
|
+
- **Tech stack normalizer**: New `tech-normalizer.ts` module handles framework name normalization, compound names ("React + TypeScript"), framework families (Next.js → React), and alias resolution (nextjs → next.js).
|
|
15
|
+
- **Anti-hallucination dedup**: Tech stack entries in the anti-hallucination block are now deduplicated using normalized matching, preventing duplicates like "React" and "react" or "Next.js" and "nextjs".
|
|
16
|
+
- **Sealed analysis priority**: Prompt builder now uses sealed analysis frameworks as primary tech stack source, falling back to repo conventions.
|
|
17
|
+
|
|
18
|
+
### Implementation Details
|
|
19
|
+
- Expanded `TaskDomain` type from fixed union to `KnownDomain | (string & {})` for forward compatibility while preserving autocomplete
|
|
20
|
+
- `DOMAIN_TAG_MAP` now includes TECH_STACK for frontend/database domains (previously missing)
|
|
21
|
+
- `SEMANTIC_DOMAIN_KEYWORDS` maps 80+ keywords across 7 domains for fuzzy domain resolution
|
|
22
|
+
- `resolveCanonicalDomains()` exported for testability — resolves arbitrary strings to known domains
|
|
23
|
+
- `normalizeFrameworkName()` handles 15 aliases; `FRAMEWORK_FAMILIES` maps 12 meta-frameworks to base frameworks
|
|
24
|
+
- `extractTechNames()` splits compound tech strings on +, /, commas, parentheses, "with", "and"
|
|
25
|
+
|
|
26
|
+
### Learnings
|
|
27
|
+
- Two-pass scoring (exact 10pts + semantic 5pts) gives gradual relevance instead of binary match/no-match
|
|
28
|
+
- TypeScript's `KnownDomain | (string & {})` pattern preserves autocomplete for known values while accepting any string
|
|
29
|
+
- Parentheses in compound names need comma replacement (not space) — otherwise adjacent words merge into a single token
|
|
30
|
+
|
|
31
|
+
### Test Plan
|
|
32
|
+
|
|
33
|
+
#### For QA
|
|
34
|
+
1. `bun test core/__tests__/agentic/semantic-matching.test.ts` — domain resolution (uxui→frontend, api→backend, infra→devops)
|
|
35
|
+
2. `bun test core/__tests__/agentic/tech-normalizer.test.ts` — normalization, compound names, framework families, dedup
|
|
36
|
+
3. `bun test core/__tests__/agentic/prompt-assembly.test.ts` — anti-hallucination block still renders correctly
|
|
37
|
+
4. `bun test` — 848 tests pass, 0 fail
|
|
38
|
+
|
|
39
|
+
#### For Users
|
|
40
|
+
- Memory retrieval automatically surfaces related memories across domains
|
|
41
|
+
- No user action needed — improvements are automatic
|
|
42
|
+
- No breaking changes
|
|
43
|
+
|
|
44
|
+
## [1.14.0] - 2026-02-09
|
|
45
|
+
|
|
46
|
+
### Features
|
|
47
|
+
|
|
48
|
+
- add sprint-based velocity calculation with trend detection (PRJ-296) (#156)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
## [1.14.0] - 2026-02-09
|
|
52
|
+
|
|
53
|
+
### Features
|
|
54
|
+
- **Velocity Dashboard**: New `prjct velocity` command with sprint-by-sprint breakdown, trend detection, and estimation accuracy (PRJ-296)
|
|
55
|
+
- **Estimation Patterns**: Automatic detection of over/under estimation patterns by task category
|
|
56
|
+
- **Completion Projections**: Given remaining backlog points, projects estimated sprints and completion date
|
|
57
|
+
- **Velocity Context Injection**: Historical velocity data automatically injected into LLM task prompts for better estimation
|
|
58
|
+
|
|
59
|
+
### Implementation Details
|
|
60
|
+
|
|
61
|
+
**PRJ-296 — Sprint-Based Velocity Calculation**
|
|
62
|
+
New velocity subsystem that aggregates completed task data (from outcomes.jsonl) into sprint periods, calculates rolling velocity metrics, detects trends, and identifies estimation patterns.
|
|
63
|
+
|
|
64
|
+
Key changes:
|
|
65
|
+
- `core/schemas/velocity.ts` — Zod schemas: SprintVelocity, VelocityMetrics, VelocityConfig, EstimationPattern, CompletionProjection
|
|
66
|
+
- `core/domain/velocity.ts` — Velocity engine: sprint bucketing, linear regression trend detection, accuracy tracking, pattern detection, duration parsing, LLM context formatting
|
|
67
|
+
- `core/storage/velocity-storage.ts` — Write-through storage extending StorageManager with markdown generation
|
|
68
|
+
- `core/commands/velocity.ts` — Dashboard command with chalk-formatted output + registration
|
|
69
|
+
- `core/types/agentic.ts` — Extended `OrchestratorContext` with `velocityContext` field
|
|
70
|
+
- `core/agentic/orchestrator-executor.ts` — Loads velocity context in parallel via `Promise.all`
|
|
71
|
+
- `core/agentic/prompt-builder.ts` — Injects velocity into Section 6 (task context)
|
|
72
|
+
- `core/__tests__/domain/velocity.test.ts` — 35 new tests
|
|
73
|
+
|
|
74
|
+
### Learnings
|
|
75
|
+
- Derive story points from estimated duration via Fibonacci mapping when outcomes lack explicit point data
|
|
76
|
+
- Linear regression slope normalized by average velocity works well for trend detection (>10% = improving, <-10% = declining)
|
|
77
|
+
- Parallel loading pattern in orchestrator-executor (`Promise.all`) ensures zero-latency context enrichment
|
|
78
|
+
|
|
79
|
+
### Test Plan
|
|
80
|
+
|
|
81
|
+
#### For QA
|
|
82
|
+
1. Run `prjct velocity` on a project with outcomes data — verify sprint-by-sprint breakdown with points, tasks, accuracy
|
|
83
|
+
2. Run `prjct velocity` with no outcomes — verify graceful "No velocity data yet" message
|
|
84
|
+
3. Run `prjct velocity 89` — verify completion projection (sprints remaining + date)
|
|
85
|
+
4. Run `bun test core/__tests__/domain/velocity.test.ts` — 35 tests pass
|
|
86
|
+
5. Run `bun test` — all 805 tests pass
|
|
87
|
+
|
|
88
|
+
#### For Users
|
|
89
|
+
- **What changed:** New `prjct velocity` command shows sprint velocity, estimation accuracy trends, and completion projections. Velocity data is automatically injected into task prompts for better LLM estimation.
|
|
90
|
+
- **How to use:** Run `prjct velocity` after completing tasks with estimates. Add backlog points: `prjct velocity 89`
|
|
91
|
+
- **Breaking changes:** None
|
|
92
|
+
|
|
3
93
|
## [1.13.0] - 2026-02-09
|
|
4
94
|
|
|
5
95
|
### Features
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
DOMAIN_TAG_MAP,
|
|
4
|
+
resolveCanonicalDomains,
|
|
5
|
+
SEMANTIC_DOMAIN_KEYWORDS,
|
|
6
|
+
} from '../../agentic/memory-system'
|
|
7
|
+
import { KNOWN_DOMAINS, MEMORY_TAGS } from '../../types/memory'
|
|
8
|
+
|
|
9
|
+
describe('semantic domain matching (PRJ-300)', () => {
|
|
10
|
+
describe('resolveCanonicalDomains', () => {
|
|
11
|
+
it('should pass through known domains unchanged', () => {
|
|
12
|
+
expect(resolveCanonicalDomains('frontend')).toEqual(['frontend'])
|
|
13
|
+
expect(resolveCanonicalDomains('backend')).toEqual(['backend'])
|
|
14
|
+
expect(resolveCanonicalDomains('devops')).toEqual(['devops'])
|
|
15
|
+
expect(resolveCanonicalDomains('testing')).toEqual(['testing'])
|
|
16
|
+
expect(resolveCanonicalDomains('database')).toEqual(['database'])
|
|
17
|
+
expect(resolveCanonicalDomains('general')).toEqual(['general'])
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('should resolve "uxui" to frontend', () => {
|
|
21
|
+
const result = resolveCanonicalDomains('uxui')
|
|
22
|
+
expect(result).toContain('frontend')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('should resolve "ui" to frontend', () => {
|
|
26
|
+
const result = resolveCanonicalDomains('ui')
|
|
27
|
+
expect(result).toContain('frontend')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('should resolve "api" to backend', () => {
|
|
31
|
+
const result = resolveCanonicalDomains('api')
|
|
32
|
+
expect(result).toContain('backend')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('should resolve "ml-pipeline" to backend', () => {
|
|
36
|
+
const result = resolveCanonicalDomains('ml-pipeline')
|
|
37
|
+
// "pipeline" matches devops, so it should at least resolve
|
|
38
|
+
expect(result.length).toBeGreaterThan(0)
|
|
39
|
+
expect(result).not.toEqual(['general'])
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('should resolve "infra" to devops', () => {
|
|
43
|
+
const result = resolveCanonicalDomains('infra')
|
|
44
|
+
expect(result).toContain('devops')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('should resolve "docker" to devops', () => {
|
|
48
|
+
const result = resolveCanonicalDomains('docker')
|
|
49
|
+
expect(result).toContain('devops')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('should resolve "schema" to database', () => {
|
|
53
|
+
const result = resolveCanonicalDomains('schema')
|
|
54
|
+
expect(result).toContain('database')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('should resolve "css" to frontend', () => {
|
|
58
|
+
const result = resolveCanonicalDomains('css')
|
|
59
|
+
expect(result).toContain('frontend')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('should resolve "e2e" to testing', () => {
|
|
63
|
+
const result = resolveCanonicalDomains('e2e')
|
|
64
|
+
expect(result).toContain('testing')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('should fallback to general for truly unknown domains', () => {
|
|
68
|
+
expect(resolveCanonicalDomains('quantum-computing')).toEqual(['general'])
|
|
69
|
+
expect(resolveCanonicalDomains('astrology')).toEqual(['general'])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('should handle case-insensitive and separator-insensitive', () => {
|
|
73
|
+
expect(resolveCanonicalDomains('UI')).toContain('frontend')
|
|
74
|
+
expect(resolveCanonicalDomains('API')).toContain('backend')
|
|
75
|
+
expect(resolveCanonicalDomains('e_2_e')).toContain('testing')
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('DOMAIN_TAG_MAP', () => {
|
|
80
|
+
it('should include TECH_STACK for frontend (improved from PRJ-107)', () => {
|
|
81
|
+
expect(DOMAIN_TAG_MAP.frontend).toContain(MEMORY_TAGS.TECH_STACK)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('should include TECH_STACK for database', () => {
|
|
85
|
+
expect(DOMAIN_TAG_MAP.database).toContain(MEMORY_TAGS.TECH_STACK)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('should include DEPENDENCIES for testing', () => {
|
|
89
|
+
expect(DOMAIN_TAG_MAP.testing).toContain(MEMORY_TAGS.DEPENDENCIES)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('should include all MEMORY_TAGS for general', () => {
|
|
93
|
+
const allTags = Object.values(MEMORY_TAGS)
|
|
94
|
+
for (const tag of allTags) {
|
|
95
|
+
expect(DOMAIN_TAG_MAP.general).toContain(tag)
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('should have mappings for all known domains', () => {
|
|
100
|
+
for (const domain of KNOWN_DOMAINS) {
|
|
101
|
+
expect(DOMAIN_TAG_MAP[domain]).toBeDefined()
|
|
102
|
+
expect(DOMAIN_TAG_MAP[domain].length).toBeGreaterThan(0)
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
describe('SEMANTIC_DOMAIN_KEYWORDS', () => {
|
|
108
|
+
it('should map uxui-related keywords to frontend', () => {
|
|
109
|
+
expect(SEMANTIC_DOMAIN_KEYWORDS.frontend).toContain('uxui')
|
|
110
|
+
expect(SEMANTIC_DOMAIN_KEYWORDS.frontend).toContain('ui')
|
|
111
|
+
expect(SEMANTIC_DOMAIN_KEYWORDS.frontend).toContain('ux')
|
|
112
|
+
expect(SEMANTIC_DOMAIN_KEYWORDS.frontend).toContain('css')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('should map CI/CD keywords to devops', () => {
|
|
116
|
+
expect(SEMANTIC_DOMAIN_KEYWORDS.devops).toContain('ci')
|
|
117
|
+
expect(SEMANTIC_DOMAIN_KEYWORDS.devops).toContain('cd')
|
|
118
|
+
expect(SEMANTIC_DOMAIN_KEYWORDS.devops).toContain('docker')
|
|
119
|
+
expect(SEMANTIC_DOMAIN_KEYWORDS.devops).toContain('kubernetes')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('should have keywords for all known domains except general', () => {
|
|
123
|
+
for (const domain of KNOWN_DOMAINS) {
|
|
124
|
+
expect(SEMANTIC_DOMAIN_KEYWORDS[domain]).toBeDefined()
|
|
125
|
+
if (domain !== 'general') {
|
|
126
|
+
expect(SEMANTIC_DOMAIN_KEYWORDS[domain].length).toBeGreaterThan(0)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
})
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
deduplicateTechStack,
|
|
4
|
+
extractTechNames,
|
|
5
|
+
getFrameworkFamily,
|
|
6
|
+
matchesTech,
|
|
7
|
+
normalizeFrameworkName,
|
|
8
|
+
} from '../../agentic/tech-normalizer'
|
|
9
|
+
|
|
10
|
+
describe('tech-normalizer', () => {
|
|
11
|
+
describe('normalizeFrameworkName', () => {
|
|
12
|
+
it('should lowercase and trim', () => {
|
|
13
|
+
expect(normalizeFrameworkName(' TypeScript ')).toBe('typescript')
|
|
14
|
+
expect(normalizeFrameworkName('React')).toBe('react')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('should resolve aliases', () => {
|
|
18
|
+
expect(normalizeFrameworkName('NodeJS')).toBe('node')
|
|
19
|
+
expect(normalizeFrameworkName('ts')).toBe('typescript')
|
|
20
|
+
expect(normalizeFrameworkName('js')).toBe('javascript')
|
|
21
|
+
expect(normalizeFrameworkName('pg')).toBe('postgres')
|
|
22
|
+
expect(normalizeFrameworkName('postgresql')).toBe('postgres')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('should preserve dotted names', () => {
|
|
26
|
+
expect(normalizeFrameworkName('Next.js')).toBe('next.js')
|
|
27
|
+
expect(normalizeFrameworkName('Vue.js')).toBe('vue')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('should handle nextjs alias', () => {
|
|
31
|
+
expect(normalizeFrameworkName('nextjs')).toBe('next.js')
|
|
32
|
+
expect(normalizeFrameworkName('nuxtjs')).toBe('nuxt.js')
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('extractTechNames', () => {
|
|
37
|
+
it('should split on + separator', () => {
|
|
38
|
+
expect(extractTechNames('React + TypeScript')).toEqual(['react', 'typescript'])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('should extract from parentheses', () => {
|
|
42
|
+
expect(extractTechNames('Next.js (React)')).toEqual(['next.js', 'react'])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('should split on "with"', () => {
|
|
46
|
+
expect(extractTechNames('Hono with Zod')).toEqual(['hono', 'zod'])
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('should split on commas', () => {
|
|
50
|
+
expect(extractTechNames('React, Vue, Angular')).toEqual(['react', 'vue', 'angular'])
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('should handle single name', () => {
|
|
54
|
+
expect(extractTechNames('React')).toEqual(['react'])
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('getFrameworkFamily', () => {
|
|
59
|
+
it('should resolve meta-frameworks to base', () => {
|
|
60
|
+
expect(getFrameworkFamily('next.js')).toBe('react')
|
|
61
|
+
expect(getFrameworkFamily('Next.js')).toBe('react')
|
|
62
|
+
expect(getFrameworkFamily('Remix')).toBe('react')
|
|
63
|
+
expect(getFrameworkFamily('Gatsby')).toBe('react')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('should resolve Vue meta-frameworks', () => {
|
|
67
|
+
expect(getFrameworkFamily('nuxt')).toBe('vue')
|
|
68
|
+
expect(getFrameworkFamily('Nuxt.js')).toBe('vue')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('should resolve Svelte meta-framework', () => {
|
|
72
|
+
expect(getFrameworkFamily('SvelteKit')).toBe('svelte')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('should return name itself for base frameworks', () => {
|
|
76
|
+
expect(getFrameworkFamily('React')).toBe('react')
|
|
77
|
+
expect(getFrameworkFamily('Express')).toBe('express')
|
|
78
|
+
expect(getFrameworkFamily('Hono')).toBe('hono')
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('matchesTech', () => {
|
|
83
|
+
it('should match exact names (case-insensitive)', () => {
|
|
84
|
+
expect(matchesTech('React', 'react')).toBe(true)
|
|
85
|
+
expect(matchesTech('react', 'React')).toBe(true)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('should match via framework family', () => {
|
|
89
|
+
expect(matchesTech('Next.js', 'react')).toBe(true)
|
|
90
|
+
expect(matchesTech('Nuxt', 'vue')).toBe(true)
|
|
91
|
+
expect(matchesTech('SvelteKit', 'svelte')).toBe(true)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('should match compound names', () => {
|
|
95
|
+
expect(matchesTech('React + TypeScript', 'react')).toBe(true)
|
|
96
|
+
expect(matchesTech('React + TypeScript', 'typescript')).toBe(true)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('should match parenthesized names', () => {
|
|
100
|
+
expect(matchesTech('Next.js (React)', 'react')).toBe(true)
|
|
101
|
+
expect(matchesTech('Next.js (React)', 'next.js')).toBe(true)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('should not match unrelated frameworks', () => {
|
|
105
|
+
expect(matchesTech('Vue', 'react')).toBe(false)
|
|
106
|
+
expect(matchesTech('Angular', 'react')).toBe(false)
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
describe('deduplicateTechStack', () => {
|
|
111
|
+
it('should remove case-insensitive duplicates', () => {
|
|
112
|
+
expect(deduplicateTechStack(['React', 'react', 'REACT'])).toEqual(['React'])
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('should remove alias duplicates', () => {
|
|
116
|
+
expect(deduplicateTechStack(['TypeScript', 'ts'])).toEqual(['TypeScript'])
|
|
117
|
+
expect(deduplicateTechStack(['Node.js', 'nodejs'])).toEqual(['Node.js'])
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('should preserve unique entries', () => {
|
|
121
|
+
expect(deduplicateTechStack(['React', 'Next.js', 'TypeScript'])).toEqual([
|
|
122
|
+
'React',
|
|
123
|
+
'Next.js',
|
|
124
|
+
'TypeScript',
|
|
125
|
+
])
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('should preserve first occurrence casing', () => {
|
|
129
|
+
expect(deduplicateTechStack(['Next.js', 'nextjs', 'NEXT.JS'])).toEqual(['Next.js'])
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('should handle empty array', () => {
|
|
133
|
+
expect(deduplicateTechStack([])).toEqual([])
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
})
|