Skip to content

Commit d948da8

Browse files
committed
ci: add GitHub workflow and AD-004 framework cache tests
AD-005: workflow on push/PR to main (Node 22, npm ci) runs lint, format:check, test, test:e2e, build. AD-004: JSDoc for LRU keying; unit tests for cache hit, separate keys, eviction re-detect. Update STATE and CONCERNS. Made-with: Cursor
1 parent 97b28e2 commit d948da8

5 files changed

Lines changed: 161 additions & 6 deletions

File tree

.github/workflows/ci.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main, master]
6+
pull_request:
7+
8+
jobs:
9+
build:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- uses: actions/checkout@v4
13+
- uses: actions/setup-node@v4
14+
with:
15+
node-version: "22"
16+
cache: npm
17+
- name: Install
18+
run: npm ci
19+
- name: Lint
20+
run: npm run lint
21+
- name: Format
22+
run: npm run format:check
23+
- name: Unit tests
24+
run: npm test
25+
- name: E2E tests
26+
run: npm run test:e2e
27+
- name: Build
28+
run: npm run build

.specs/codebase/CONCERNS.md

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
**Analysis date:** 2026-04-25
44

5-
**Project state (decisions, todos, deferred work):** [`.specs/project/STATE.md`](../project/STATE.md) — e.g. **AD-001****AD-003** status, **Active blockers** (none as of last update), and **Deferred ideas** (full Angular / Laravel adapters).
5+
**Project state (decisions, todos, deferred work):** [`.specs/project/STATE.md`](../project/STATE.md) — e.g. **AD-001****AD-005** status, **Active blockers** (none as of last update), and **Deferred ideas** (full Angular / Laravel adapters).
66

77
## Tech debt
88

@@ -18,6 +18,8 @@
1818
- **Impact:** Same user-facing gap until real adapters are registered. Do not claim full Angular / Laravel support in product copy until adapter-level tests cover those surfaces (per AD-003).
1919
- **Next (matches STATE *Deferred ideas*):** Implement adapter services, register them in `FrameworkAdapterRegistryService`, and add tests before marketing support.
2020

21+
**Documented in code and tests (AD-004, 2026-04-25):** `FrameworkDetectorService` documents LRU (max 10) keying on the raw project root string; unit tests assert cache hits, separate keys for different strings, and re-detection after eviction. See `src/mcp/core/data-access/services/framework-detector.service.ts` and `test/unit/core/data-access/services/framework-detector.service.spec.ts`.
22+
2123
## Security considerations
2224

2325
**HTTP deployment without application-level auth**
@@ -55,11 +57,7 @@
5557

5658
## Test / ops gaps
5759

58-
**No CI config in repository**
59-
60-
- **Evidence:** No `.github/workflows` or `.gitlab-ci.yml` found in the workspace at analysis time.
61-
- **Impact:** Build/test discipline relies on local runs or off-repo automation.
62-
- **Fix approach:** Add a minimal pipeline running `lint`, `test`, `test:e2e`, `build` on push/PR.
60+
**Resolved (AD-005, 2026-04-25):** GitHub Actions workflow `.github/workflows/ci.yml` runs on push/PR to `main`/`master`: `lint`, `format:check`, `test`, `test:e2e`, `build` (Node 22, `npm ci`).
6361

6462
**Coverage excludes ports and placeholder domains**
6563

.specs/project/STATE.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,34 @@
4949

5050
---
5151

52+
### AD-004: Framework detection cache — keying and LRU (2026-04-25)
53+
54+
**Decision:** Keep the current LRU cache (max 10 keys) in `FrameworkDetectorService`, keyed by the *resolved* project root string. Do not add canonical-path normalization in this pass unless profiling or user reports show duplicate entries for the same logical project as a problem.
55+
56+
**Reason:** CONCERNS notes that different string forms of the “same” root (e.g. path variants) can cache separately; capacity is small and bounded. Changing keying (e.g. `realpath`) is a trade-off and needs targeted tests.
57+
58+
**Trade-off:** Possible duplicate cache slots for equivalent roots; mitigated by LRU size and test coverage in `framework-detector.service.spec.ts`.
59+
60+
**Impact:** `src/mcp/core/data-access/services/framework-detector.service.ts` (see **Fragile areas** in `.specs/codebase/CONCERNS.md`).
61+
62+
**Status:** **Implemented** — JSDoc on `FrameworkDetectorService` and `CACHE_MAX_SIZE`; unit tests cover per-root caching, separate keys for distinct path strings, and re-detection after eviction (`test/unit/core/data-access/services/framework-detector.service.spec.ts`).
63+
64+
---
65+
66+
### AD-005: In-repo CI pipeline (2026-04-25)
67+
68+
**Decision:** Add a GitHub Actions workflow; keep local `npm run precommit` (lint, format, test) for pre-push discipline. The pipeline should run `lint`, `format:check`, `test`, `test:e2e`, and `build` on push/PR to `main`/`master`, matching CONCERNS *Test / ops gaps*.
69+
70+
**Reason:** Automated verification in-repo; catches regressions for contributors and forks.
71+
72+
**Trade-off:** CI minutes and e2e runtime (~40s+ for stdio); Node version pinned in workflow (22).
73+
74+
**Impact:** `.github/workflows/ci.yml`; see `.specs/codebase/CONCERNS.md`.
75+
76+
**Status:** **Implemented**`ci.yml` uses `actions/checkout` and `actions/setup-node` (Node 22, `npm ci`).
77+
78+
---
79+
5280
## Active blockers
5381

5482
_(None.)_
@@ -68,6 +96,7 @@ _(None yet.)_
6896
| 001 | Initialize `.specs/project/` (TLC) | 2026-04-25 | e35b801 | Done |
6997
| 002 | Brownfield map — 7 files in `.specs/codebase/` | 2026-04-25 | e35b801 | Done |
7098
| 003 | Implement AD-001, AD-002, AD-003 (code + tests) | 2026-04-25 | e35b801 | Done |
99+
| 004 | **AD-004** cache docs/tests; **AD-005** GitHub Actions CI | 2026-04-25 || Done |
71100

72101
---
73102

@@ -84,6 +113,9 @@ _(None yet.)_
84113
- [x] Implement **AD-001** — env schema: remove non-portable `PROJECT_ROOT` default
85114
- [x] Implement **AD-002**`McpModule.forRoot` version from `package.json`
86115
- [x] **AD-003** initial tranche — wire placeholder domain modules, document extension points, expand detection tests
116+
- [x] Record **AD-004** (framework detection cache) and **AD-005** (CI gap) from **CONCERNS**
117+
- [x] Implement **AD-004** — JSDoc + cache/LRU/eviction unit tests
118+
- [x] Implement **AD-005**`.github/workflows/ci.yml`
87119

88120
---
89121

src/mcp/core/data-access/services/framework-detector.service.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,14 @@ import { ProjectRootContextService } from '@/mcp/core/data-access/services/proje
44

55
export type FrameworkType = 'nestjs' | 'angular' | 'laravel' | null;
66

7+
/** AD-004: max distinct project-root *strings* (LRU evicts oldest insertion when full). */
78
const CACHE_MAX_SIZE = 10;
89

10+
/**
11+
* Caches `detect()` per `getProjectRoot()` return value. Keys are not normalized
12+
* (e.g. two strings that resolve to the same path may get separate entries).
13+
* See `framework-detector.service.spec.ts` and `.specs/project/STATE.md` (AD-004).
14+
*/
915
@Injectable()
1016
export class FrameworkDetectorService {
1117
private readonly cache = new Map<string, Promise<FrameworkType>>();

test/unit/core/data-access/services/framework-detector.service.spec.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,4 +189,95 @@ describe('FrameworkDetectorService', () => {
189189

190190
expect(fileReader.readFile).toHaveBeenCalled();
191191
});
192+
193+
/** AD-004: in-memory key is the raw project root string; same root reuses one detection. */
194+
it('should call readFile once for package.json when detecting twice for the same root', async () => {
195+
fileReader.readFile.mockImplementation((path: string) => {
196+
if (path === 'package.json') {
197+
return Promise.resolve(
198+
JSON.stringify({
199+
dependencies: { '@nestjs/core': '^11' },
200+
}),
201+
);
202+
}
203+
return Promise.resolve(null);
204+
});
205+
206+
await sut.detect();
207+
await sut.detect();
208+
209+
const pkgCalls = fileReader.readFile.mock.calls.filter((c) => c[0] === 'package.json');
210+
expect(pkgCalls).toHaveLength(1);
211+
});
212+
213+
/** AD-004: different root strings are separate cache keys (no path canonicalization). */
214+
it('should not share cache between different project root strings', async () => {
215+
let n = 0;
216+
const projectRootContext = {
217+
getProjectRoot: jest.fn().mockImplementation(() => (n++ === 0 ? '/a/x' : '/b/y')),
218+
} as unknown as jest.Mocked<ProjectRootContextService>;
219+
220+
const module: TestingModule = await Test.createTestingModule({
221+
providers: [
222+
FrameworkDetectorService,
223+
{ provide: FileReaderService, useValue: fileReader },
224+
{ provide: ProjectRootContextService, useValue: projectRootContext },
225+
],
226+
}).compile();
227+
228+
const detector = module.get(FrameworkDetectorService);
229+
fileReader.readFile.mockImplementation((path: string) => {
230+
if (path === 'package.json') {
231+
return Promise.resolve(
232+
JSON.stringify({
233+
dependencies: { '@nestjs/core': '^11' },
234+
}),
235+
);
236+
}
237+
return Promise.resolve(null);
238+
});
239+
240+
await detector.detect();
241+
await detector.detect();
242+
243+
const pkgCalls = fileReader.readFile.mock.calls.filter((c) => c[0] === 'package.json');
244+
expect(pkgCalls).toHaveLength(2);
245+
});
246+
247+
/**
248+
* AD-004: when the 11th distinct root is added, the oldest key is evicted; a later
249+
* detect for that evicted root runs uncached detection again.
250+
*/
251+
it('should re-run detection for an evicted project root', async () => {
252+
const projectRootContext = {
253+
getProjectRoot: jest.fn(),
254+
} as unknown as jest.Mocked<ProjectRootContextService>;
255+
256+
const module: TestingModule = await Test.createTestingModule({
257+
providers: [
258+
FrameworkDetectorService,
259+
{ provide: FileReaderService, useValue: fileReader },
260+
{ provide: ProjectRootContextService, useValue: projectRootContext },
261+
],
262+
}).compile();
263+
264+
const detector = module.get(FrameworkDetectorService);
265+
fileReader.readFile.mockResolvedValue(null);
266+
267+
for (let i = 0; i < 10; i++) {
268+
projectRootContext.getProjectRoot.mockReturnValue(`/r${i}`);
269+
await detector.detect();
270+
}
271+
272+
const callsAfterTen = fileReader.readFile.mock.calls.length;
273+
expect(callsAfterTen).toBeGreaterThan(0);
274+
275+
projectRootContext.getProjectRoot.mockReturnValue('/r10');
276+
await detector.detect();
277+
278+
projectRootContext.getProjectRoot.mockReturnValue('/r0');
279+
await detector.detect();
280+
281+
expect(fileReader.readFile.mock.calls.length).toBeGreaterThan(callsAfterTen);
282+
});
192283
});

0 commit comments

Comments
 (0)