diff --git a/README.md b/README.md index 034f8b7..9260a21 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg?style=flat-square)](LICENSE) [![TypeScript](https://img.shields.io/badge/TypeScript-5.9-blue?style=flat-square&logo=typescript)](https://www.typescriptlang.org/) -[![Tests](https://img.shields.io/badge/tests-2096%20passing-success?style=flat-square)](tests/) +[![Tests](https://img.shields.io/badge/tests-2112%20passing-success?style=flat-square)](tests/) [![Node](https://img.shields.io/badge/node-%3E%3D22.13-brightgreen?style=flat-square&logo=node.js)](https://nodejs.org/) [![pnpm](https://img.shields.io/badge/pnpm-%3E%3D11-orange?style=flat-square&logo=pnpm)](https://pnpm.io/) @@ -740,7 +740,7 @@ src/ ## Testing ```bash -# Run all tests (2096 tests) +# Run all tests (2112 tests) pnpm test # Watch mode @@ -753,8 +753,8 @@ pnpm run typecheck pnpm run lint ``` -**Test Coverage**: 2096 tests across 4 categories: -- Unit tests (types + lib + services + cli): 1366 tests +**Test Coverage**: 2112 tests across 4 categories: +- Unit tests (types + lib + services + cli): 1382 tests - Contract tests (CLI output + Skill format): 647 tests - Integration tests: 38 tests - E2E tests: 45 tests diff --git a/README.zh-TW.md b/README.zh-TW.md index d12ad54..62ff7ea 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -4,7 +4,7 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg?style=flat-square)](LICENSE) [![TypeScript](https://img.shields.io/badge/TypeScript-5.9-blue?style=flat-square&logo=typescript)](https://www.typescriptlang.org/) -[![測試](https://img.shields.io/badge/測試-2096%20通過-success?style=flat-square)](tests/) +[![測試](https://img.shields.io/badge/測試-2112%20通過-success?style=flat-square)](tests/) [![Node](https://img.shields.io/badge/node-%3E%3D22.13-brightgreen?style=flat-square&logo=node.js)](https://nodejs.org/) [![pnpm](https://img.shields.io/badge/pnpm-%3E%3D11-orange?style=flat-square&logo=pnpm)](https://pnpm.io/) @@ -711,7 +711,7 @@ src/ ## 測試 ```bash -# 執行所有測試(2096 個測試) +# 執行所有測試(2112 個測試) pnpm test # Watch 模式 @@ -724,8 +724,8 @@ pnpm run typecheck pnpm run lint ``` -**測試覆蓋率**:2096 個測試橫跨 4 大類: -- Unit tests(types + lib + services + cli):1366 tests +**測試覆蓋率**:2112 個測試橫跨 4 大類: +- Unit tests(types + lib + services + cli):1382 tests - Contract tests(CLI 輸出 + Skill 格式):647 tests - Integration tests:38 tests - E2E tests:45 tests diff --git a/prospec/ai-knowledge/_lessons-ledger.md b/prospec/ai-knowledge/_lessons-ledger.md index c1ce4c6..89b8aef 100644 --- a/prospec/ai-knowledge/_lessons-ledger.md +++ b/prospec/ai-knowledge/_lessons-ledger.md @@ -43,11 +43,12 @@ | workflow/user-confirm-timeout-proceed-with-record | 需要使用者即時確認的規劃決策(如 scale 確認)逾時無回應 → 以推薦預設續行是正確的自主行為,但必須三處留痕:quality_log WARN、proposal Open Questions、最終摘要明示可覆寫點——讓事後批准(使用者以 y 續行)有據可查。fix-upgrade-doc-coverage:AskUserQuestion 60s 逾時,scale: standard 依準則表+前例採用並全程留痕,verify 前使用者實質批准;add-plan-flow-diagram 第 2 度:new-story AskUserQuestion 逾時,四項設計選項(觸發/位置/內容/scale)依 issue 意圖採推薦預設並三處留痕(quality_log WARN、proposal Open Questions、摘要明示可覆寫),使用者事後以 y 續行並確認 any-of 判準 | 2 | — | playbook | fix-upgrade-doc-coverage, add-plan-flow-diagram | personal | | types/type-name-contradicts-role-semantics | 型別名承載的語意與使用場景矛盾(CanonicalDoc 註解宣稱 never user-customized,卻被 user-managed 清單重用)是 load-bearing 誤導——共用「形狀」時型別名應角色中性(如 ConventionDocSource),ownership 語意放在各 const 註解;proposal 載明的結構名(UserManagedDoc)被實作靜默改用他型也屬同型偏差。dedupe-init-doc-registry review major,實作者採納改名修復 | 1 | 1 (types) | convention | dedupe-init-doc-registry | personal | | test/consistency-guard-over-extraction | 同一份指引為 skill 自包含而刻意重述於兩範本(plan-format Section 5 與 prospec-plan Phase 4 的 any-of 門檻,比照 Context7 前例)時,抽共用 partial 會因兩處需要不同「高度」的敘述而變形、且與既有 Context7 不一致、降低範本可讀性——正解是加 section-scoped 跨檔一致性契約斷言釘住同一組 token(mutation-verified),保留兩形態又擋漂移。與 refactor/duplicated-helper-parallel-sites(PB-006,抽 helper)互補:程式邏輯重複抽 helper、指令散文重述加一致性守衛 | 1 | 2 (templates, tests) | convention | add-plan-flow-diagram | personal | -| test/e2e-help-timeout-flaky-under-load | `tests/e2e/cli.test.ts`「prospec --help」在 full-suite 併發滿載下 5s testTimeout 逾時(cold CLI spawn 延遲,非邏輯缺陷);單獨跑該檔 43/43 全過、與變更無關、full-suite 連兩跑皆重現。修法:調高該 e2e 的 testTimeout 或降低併發壓力。首見於 generate-factual-counts verify 5/5 WARN(環境性,未阻 grade A);sync-knowledge-at-verify-commit 第 2 度:part b verify 5/5 再現同一 flake(滿載下觸發、單獨 43/43 全過),確認為穩定的環境性 flake——建議調高該 e2e testTimeout;mechanize-review-gate 第 3 度:verify 5/5 full-suite 再現(單檔 43/43 綠、未阻 grade A);converge-constitution-audit 第 4 度:同一 e2e `--help`/`init` cold-spawn timeout 於 full-suite 再現(單檔綠、template-only 變更無關)——freq=4 但 impact_modules=1(tests)< 2,維持 personal 不晉升;建議調高該 e2e testTimeout 一次解決 | 4 | 1 (tests) | playbook | generate-factual-counts, sync-knowledge-at-verify-commit, mechanize-review-gate, converge-constitution-audit | personal | +| test/e2e-help-timeout-flaky-under-load | `tests/e2e/cli.test.ts`「prospec --help」在 full-suite 併發滿載下 5s testTimeout 逾時(cold CLI spawn 延遲,非邏輯缺陷);單獨跑該檔 43/43 全過、與變更無關、full-suite 連兩跑皆重現。修法:調高該 e2e 的 testTimeout 或降低併發壓力。首見於 generate-factual-counts verify 5/5 WARN(環境性,未阻 grade A);sync-knowledge-at-verify-commit 第 2 度:part b verify 5/5 再現同一 flake(滿載下觸發、單獨 43/43 全過),確認為穩定的環境性 flake——建議調高該 e2e testTimeout;mechanize-review-gate 第 3 度:verify 5/5 full-suite 再現(單檔 43/43 綠、未阻 grade A);converge-constitution-audit 第 4 度:同一 e2e `--help`/`init` cold-spawn timeout 於 full-suite 再現(單檔綠、template-only 變更無關)——freq=4 但 impact_modules=1(tests)< 2,維持 personal 不晉升;建議調高該 e2e testTimeout 一次解決;support-file-module-paths 第 5 度:`tests/contract/mcp-server.test.ts` 的 knowledge_health byte-for-byte 測試(gitFixture + checkExecute,git-subprocess 密集)於 full-suite 併發滿載下 5s 逾時(單檔 1.3s、與變更無關、連兩跑重現),本次直接採建議修法——為該測試加 20s testTimeout headroom,full-suite 3/3 穩定綠;再證此 flake 不限 e2e `--help`,凡 subprocess/spawn 密集測試在預設 5s 下皆有風險,宜統一調高或降併發 | 5 | 1 (tests) | playbook | generate-factual-counts, sync-knowledge-at-verify-commit, mechanize-review-gate, converge-constitution-audit, support-file-module-paths | personal | | docs/module-readme-manual-counts-uncovered | #65 的 counts 工具(`scripts/sync-counts.ts`)覆蓋 root README.md/README.zh-TW.md/index.md/tests README 的測試計數 + `.hbs`/skill/reference inventory,但**不涵蓋各模組 README 的「(N files, N lines)」標頭**——這些仍手動維護、隨 source 改動漂移(mechanize-review-gate archive 時 lib 等模組 README 行數已失準、`pnpm counts:check` 未攔、verify V4 亦不驗)。建議擴充 counts 工具覆蓋 per-module 檔/行計數(或改為 badge 生成),把最後一類手動計數也機器化。與 docs/duplicated-count-drift(PB-004,已 retired)同族、覆蓋面互補 | 1 | 2 (tests, lib) | convention | mechanize-review-gate | personal | | spec/behavior-change-omits-modified-req | 變更改動了既有 named REQ(如 REQ-AGNT-020「entry config 列 per-skill Triggers」)所治理的行為,但 delta-spec 只 ADDED 新 REQ、未把該既有 REQ 列 MODIFIED → archive Phase 3.5 graduation 只寫新 REQ、既有矛盾條款留存 → 自我矛盾的 Feature Spec。plan/delta-spec 階段須 grep 受影響行為對應的既有 REQ(及其 User Story 驗收場景)並列 MODIFIED replace-in-place。slim-skill-trigger-context review 對抗式揪出(confirmed critical):slim CLAUDE.md 牴觸 REQ-AGNT-020,補 delta-spec MODIFIED + US-412 場景,archive 已 replace-in-place。與 knowledge/req-citation-precedes-graduation 同屬 spec-graduation 家族 | 1 | 2 (templates, tests) | playbook | slim-skill-trigger-context | personal | | docs/readme-check-enumeration-manual | 每新增一個 drift check,root README(`prospec check` 說明枚舉的檢查種類,README:639)就漂移——`pnpm counts` 只覆蓋數字,這段散文枚舉無單一來源、無機器守門:#66 review-provenance、#67 metadata-completeness、#63 knowledge-size 皆未同步入該清單(reviewer 對抗式揪出,verify [SHOULD] User-Facing-Doc 於 feature commit 補列 knowledge-size)。建議把該枚舉由 `DRIFT_CHECK_IDS`+描述生成,或加一致性契約斷言釘住「README 枚舉 ⊇ 現有 check 種類」。與 docs/module-readme-manual-counts-uncovered 同族(counts 工具未覆蓋的手動 README 內容) | 3 | 2 (lib, cli) | playbook | mechanize-review-gate, quick-scale-and-ceremony-cleanup, enforce-knowledge-size-budget | promoted(PB-009 · Approved-by: benwu95 · 2026-07-06) | | test/typecheck-excludes-tests-hides-type-errors | `tsconfig.json` `exclude: ["tests"]` 使 `pnpm typecheck` 不檢查測試檔——測試檔的 type-only import 破損(如遷移型別後測試仍從舊模組 import → TS2459)不被 typecheck 攔、esbuild 於 runtime strip type-only import 故 vitest 亦綠、eslint 亦不報,三閘全漏,僅對抗式 review 揪出。建議 typecheck 納入 `tests/`(或加獨立 test-typecheck job),把 test 的型別破損變成機器可攔。與 refactor/relocation-reference-sweep-completeness 同案發現 | 1 | 1 (tests) | playbook | inject-resolved-knowledge-budgets | personal | +| review/proposed-fix-or-scenario-can-be-wrong | 對抗式 review 的 finding 情境或建議修法未必正確,套用前須先重現 finding、再驗證修法不引入回歸——support-file-module-paths M1:reviewer 稱非源碼檔的 fenced ```ts import 會成偽依賴邊,但實測該 fence 已被既有 template-literal 去除(backtick 觸發)抹掉、不成邊(真正向量是非源碼檔的**裸** import 行);且 reviewer 建議修法(非源碼檔回退 `${prefix}/${EXT}`)會 scandir 一個檔案 → 重引本變更剛修掉的 ENOTDIR。改以「非源碼檔 file 條目回傳 null、caller skip」正確修復並 mutation-verify。守則:review→fix loop 中,尤其對 reviewer 附帶的具體修法,先重現再驗回歸 | 1 | 1 (lib) | playbook | support-file-module-paths | personal | ## Needs-Review List diff --git a/prospec/ai-knowledge/modules/lib/README.md b/prospec/ai-knowledge/modules/lib/README.md index da01b77..a63607e 100644 --- a/prospec/ai-knowledge/modules/lib/README.md +++ b/prospec/ai-knowledge/modules/lib/README.md @@ -13,7 +13,7 @@ | `template.ts` | renderTemplate + Handlebars helpers/partials; resolveTemplatesDir; memory bundled-templates resolution | | `content-merger.ts` | mergeContent/mergeManagedDoc (preserve user blocks); hasAutoBlock/replaceAutoBlock | | `yaml-utils.ts` | parse/stringifyYaml, escapeYamlScalar, mergeIntoDocument (comment-preserving) | -| `scanner.ts` | scanDir (fast-glob, security excludes), gitTrackedOnly, filterConventions | +| `scanner.ts` | scanDir (fast-glob, security excludes), gitTrackedOnly, filterConventions, classifyModulePath/moduleScanPatterns (module-map path → scan) | | `module-detector.ts` | detectModules (auto/architecture/domain/package), buildModuleMap | | `detector.ts` | detectTechStack over 11 languages; hasCFamilySource | | `manifest-parsers.ts` | Deterministic dep/entry-point parsers (TOML/XML/hand-rolled), []-safe | @@ -27,7 +27,7 @@ Also: `token-accounting.ts`, `index-table.ts`/`index-template.ts`, `task-markers - `readConfig`/`writeConfig`/`atomicWrite`/`readFileIfExists` — validated read; comment-preserving/atomic writes; ENOENT→'' - `renderTemplate`/`readTemplateSource`/`mergeContent`/`mergeManagedDoc` — render + template source read + user-block-preserving merges -- `scanDir`/`detectModules`/`detectTechStack` — scan + module/stack detection +- `scanDir`/`moduleScanPatterns`/`classifyModulePath`/`detectModules`/`detectTechStack` — scan (with module-map file/dir/glob path classification) + module/stack detection - `parse*Dependencies(content)` — pure, malformed-safe manifest parsers - `runChecks(inputs)` + `collect*` — 11 evaluators → DriftReport; `loadModuleMap`/`loadFeatureMap`/`searchModules` — realpath-contained reads diff --git a/prospec/ai-knowledge/modules/tests/README.md b/prospec/ai-knowledge/modules/tests/README.md index 4d31c83..4f0fc8b 100644 --- a/prospec/ai-knowledge/modules/tests/README.md +++ b/prospec/ai-knowledge/modules/tests/README.md @@ -1,6 +1,6 @@ # tests -> 4-layer Vitest suite (fast-glob/git bypass memfs — 87 test files, 2,096 tests (unit 1366, contract 647, integration 38, e2e 45)); tests every source module. +> 4-layer Vitest suite (fast-glob/git bypass memfs — 87 test files, 2,112 tests (unit 1382, contract 647, integration 38, e2e 45)); tests every source module. diff --git a/prospec/ai-knowledge/raw-scan.md b/prospec/ai-knowledge/raw-scan.md index c1d6e48..818d09a 100644 --- a/prospec/ai-knowledge/raw-scan.md +++ b/prospec/ai-knowledge/raw-scan.md @@ -106,5 +106,5 @@ tests/ | Metric | Value | |--------|-------| -| Total files | 396 | +| Total files | 397 | | Scan depth | 10 | diff --git a/prospec/index.md b/prospec/index.md index 9e2a895..895ce2c 100644 --- a/prospec/index.md +++ b/prospec/index.md @@ -28,7 +28,7 @@ Load these specific convention files only when their topics are relevant to the | **services** | init, knowledge, change, archive, agent-sync, spec-sync, product, feature-map, triggers, language, measure, check, mcp, serve | 服務, 業務邏輯, business logic, execute pattern, use case, 量測報告, 漂移檢查, 真相層 | Active | Business logic — one `execute()` service per command — init / quickstart / upgrade, knowledge generate + update, change story / plan / tasks, archive + spec-sync, agent-sync, measure, drift check, and the read-only MCP server. | Isolates business logic from I/O layer, enables testability | types, lib | | **cli** | commands, formatters, commander, output, preaction, measure, check, strict, mcp, stdio | 指令, 命令列, command line, 終端, entry point | Active | Thin CLI entry — Commander commands + formatters that parse → call one service → format output. No business logic (delegates everything to services). | Thin I/O layer: no business logic, delegates to services | types, lib, services | | **templates** | handlebars, hbs, skills, agent-configs, recipe-first, loading-rules, references, change, stable-prefix, entry-gate, scale, kind, ci-workflow, flywheel, lessons-ledger, feature-map, category, grouping | 模板, 範本, handlebars, template engine, resources, 穩定前綴, 知識同步閘門, 複雜度適配, CI 閘門 | Active | Handlebars template library — 17 skills + 5 shared partials, 19 references, 1 agent-config, 4 change, 15 init/knowledge (61 `.hbs` templates). Pure resources consumed by lib/template — the source of every generated skill, README, and index. | Pure resources — no logic, consumed by lib/template.ts | — | -| **tests** | vitest, memfs, unit, integration, contract, e2e, knowledge-format, skill-format, token-corpus, drift, lessons-harvest, mcp-server, in-memory-transport | 測試, 單元測試, test suite, 驗證, vitest | Active | 4-layer test suite — 87 files, 2,096 tests (unit 1366 + contract 647 + integration 38 + e2e 45) across unit / contract / integration / e2e. Validates every module — format contracts, the drift engine, token corpus, and the MCP protocol over in-memory transport. | Quality gate — validates all layers with pyramid coverage | types, lib, services, cli, templates | +| **tests** | vitest, memfs, unit, integration, contract, e2e, knowledge-format, skill-format, token-corpus, drift, lessons-harvest, mcp-server, in-memory-transport | 測試, 單元測試, test suite, 驗證, vitest | Active | 4-layer test suite — 87 files, 2,112 tests (unit 1382 + contract 647 + integration 38 + e2e 45) across unit / contract / integration / e2e. Validates every module — format contracts, the drift engine, token corpus, and the MCP protocol over in-memory transport. | Quality gate — validates all layers with pyramid coverage | types, lib, services, cli, templates | _Table format: Module | Keywords | Aliases | Status | Description | Rationale | Depends On_ diff --git a/prospec/specs/_archived-history/2026-07-09-support-file-module-paths.md b/prospec/specs/_archived-history/2026-07-09-support-file-module-paths.md new file mode 100644 index 0000000..281611a --- /dev/null +++ b/prospec/specs/_archived-history/2026-07-09-support-file-module-paths.md @@ -0,0 +1,48 @@ +# support-file-module-paths — Archive Summary + +- **Archived**: 2026-07-09 +- **Original Created**: 2026-07-09T03:56:16.000Z +- **Quality Grade**: S + +## User Story + +As a 依賴 `prospec check` 把關依賴方向的 CI 使用者, +I want drift 的 import-direction 檢查在 `paths` 含單一檔案或資料夾時都能正確掃描與歸屬, +So that 依賴方向違規不會因為 `paths` 用了檔案條目而被無聲漏掉。 + +As a 維護 `module-map.yaml` 並執行 knowledge 產生的開發者, +I want `getModuleInfos` 對資料夾條目納入整個子樹、對檔案條目只納入該檔, +So that 產生的 module README `keyFiles` 反映真實檔案集,且與 drift 對 `paths` 的解讀一致。 + +## Affected Modules + +| Module | Impact | Description | +|--------|--------|-------------| +| lib | High | scanner 新增 classifyModulePath + moduleScanPatterns;drift importScanPattern 依分類掃描(file→該檔、非源碼檔→skip、dir→子樹、glob→verbatim) | +| services | Medium | knowledge getModuleInfos / updateModuleReadme 掃描改走 moduleScanPatterns(修裸資料夾掃 0 檔缺陷) | +| types | Low | module-map schema `paths` 欄位語義 doc comment(file / dir / glob) | +| tests | Medium | 16 個新測試案例(分類器四態、映射、drift 檔案條目/非源碼檔/glob 回歸、knowledge 子樹掃描 wiring),全數 mutation-verified | + +## Requirements + +| REQ ID | Status | Description | +|--------|--------|-------------| +| REQ-LIB-029 | ADDED | module-map `paths` 條目分類器(stat-based file/dir/glob/missing)+ moduleScanPatterns | +| REQ-TESTS-050 | ADDED | file/dir/glob 路徑跨 caller 一致性測試 | +| REQ-LIB-014 | MODIFIED | drift 引擎 import-edge 蒐集依分類器處理 file 條目 | +| REQ-KNOW-004 | MODIFIED | knowledge README 掃描透過 moduleScanPatterns 解讀 paths | + +## Completion + +- **Tasks**: 13/13 code (100%)(另 `[V]` 回歸、`[M]` 自檢各 1 皆完成) +- **Acceptance Criteria**: 7/7 WHEN/THEN(US-1 4 + US-2 3) + +## Review & Verify + +- **Review**: 2 round(s), 0 critical / 2 major — M1(非源碼檔 file 條目被 import-掃描)已修 + mutation-verified;M2(exotic 非 `*` glob 一致性)延後為 advisory +- **Verify**: Grade S, Tasks / Delta-Spec / Constitution / Knowledge / Tests 全 PASS;Vitest 全綠(2112/2112) +- **Quality Log**: review 2 majors(M1 fixed / M2 deferred);implement 1 scope-adjacent robustness note(mcp contract test 加 20s timeout headroom,平行負載 flake) + +## Knowledge Update + +- `prospec/ai-knowledge/modules/lib/README.md`(已於 feature commit `b570921` 同步新增 exports) diff --git a/prospec/specs/features/ai-knowledge.md b/prospec/specs/features/ai-knowledge.md index 408ef51..3b9a404 100644 --- a/prospec/specs/features/ai-knowledge.md +++ b/prospec/specs/features/ai-knowledge.md @@ -1,9 +1,9 @@ --- feature: ai-knowledge status: active -last_updated: 2026-07-06 -story_count: 14 -req_count: 52 +last_updated: 2026-07-09 +story_count: 15 +req_count: 54 --- # AI Knowledge @@ -90,6 +90,7 @@ req_count: 52 - WHEN generating README, THEN follow Recipe-First order: Overview → Key Files → Public API → Dependencies → Modification Guide → Ripple Effects → Pitfalls - WHEN module directory written, THEN contain only README.md (no api-surface.md or redundant files) - WHEN README.md already exists, THEN use ContentMerger to preserve user sections +- WHEN scanning a module's files for key files (`getModuleInfos` / `updateModuleReadme`), THEN interpret each `module-map.yaml` `paths` entry through `moduleScanPatterns` (REQ-LIB-029): a directory expands to its subtree, a single file scans only itself, a glob passes through verbatim — a bare directory entry no longer matches zero files #### REQ-KNOW-006: Dry-run Preview Mode - WHEN executing `prospec knowledge generate --dry-run`, THEN display file list without creating @@ -463,6 +464,33 @@ generate 依路徑/keywords/領域語意推導建議 category,經使用者確 #### REQ-TEMPLATES-141: Language Policy 豁免 AI Knowledge base Constitution Language Policy(Description/Verify/checklist/quality-standards)還原豁免 AI Knowledge base(回 `0d35f85` 前語意)——change artifacts 為 zh-TW,程式碼/commit/Knowledge base 為英文;`entry.md.hbs`(生成 CLAUDE.md/AGENTS.md)範圍收斂為 change artifacts + 明列 Knowledge base 豁免;`_lessons-ledger` header 加 description-language 宣告(ledger 描述可 zh-TW 為明文例外)。INVEST 於同一 Constitution 編修維持 `[MUST]`,僅 Verify 條款改寫使 new-story 稽核為 advisory。 +### US-361: module-map `paths` 一致支援檔案與資料夾 [P1] + +身為維護 `module-map.yaml` 並依賴 drift 與 knowledge 的開發者, +我想要 `paths` 中的檔案條目、資料夾條目、glob 條目在所有 caller 都被一致解讀, +以便把單一檔案歸到與其所在資料夾不同的模組,且 drift 與 knowledge 產生結果一致可信。 + +**Acceptance Scenarios:** +- WHEN `paths` 條目指向磁碟上存在的資料夾,THEN 掃描型 caller 納入其子樹所有檔 +- WHEN `paths` 條目指向磁碟上存在的單一檔案,THEN 掃描型 caller 只納入該檔本身 +- WHEN `paths` 條目為 glob(`**/x/**`、`dir/**`)或不存在/repo 外路徑,THEN 分別 verbatim 傳遞或退回 literal-prefix fallback(既有 glob 寫法不受影響) + +#### REQ-LIB-029: module-map `paths` 條目分類器(file/dir/glob) +lib 提供單一 stat-based 分類器 `classifyModulePath(rawPath, cwd)`(回傳 `glob | file | dir | missing`)+ `moduleScanPatterns(paths, cwd)`,作為「一個 `paths` 條目如何被掃描」的唯一事實來源;drift(REQ-LIB-014)與 knowledge(REQ-KNOW-004)掃描端共用此分類語義。 + +**Scenarios:** +- WHEN 條目含 `*`,THEN 分類為 `glob` 並原樣保留(不觸碰磁碟) +- WHEN 條目在磁碟為目錄→`dir`(映射子樹 `${p}/**`)、為檔案→`file`(映射 `${p}`) +- WHEN 條目不存在或解析後逃出 repo,THEN `missing`,退回 literal-prefix fallback(不擲錯、不改規則集,與 `clampModulePaths` 同容器守衛) + +#### REQ-TESTS-050: file/dir/glob 路徑一致性測試 +以 fixture 覆蓋分類器四態與兩個掃描 caller(drift import-edge、knowledge README 掃描)對檔案/資料夾/glob 條目的一致行為。 + +**Scenarios:** +- WHEN 跑 scanner 單元測試,THEN 涵蓋 `classifyModulePath` 四態與 `moduleScanPatterns` 映射(含 repo 外→missing) +- WHEN 跑 drift 測試,THEN 僅含檔案條目之模組回報 `available` 且掃到該檔 import;glob 條目行為不變 +- WHEN 跑 knowledge 測試,THEN 資料夾條目→非空 keyFiles、檔案條目→僅該檔(皆 mutation-verified) + ## Edge Cases - delta-spec.md 不存在:允許手動指定模組進行更新 @@ -523,3 +551,4 @@ _(None)_ | 2026-07-05 | preserve-curated-index-columns | curated index 欄位收斂為 module-map.yaml 單一真相、index.md ## Modules 由其生成;updateIndex 自 module-map 生成 + execute() no-clobber 回填遷移(下游零遺失);index-table.ts 保真工具(issue #58 根治 #57 止血的 clobber) | US-303; REQ-TYPES-056, REQ-LIB-026, REQ-KNOW-036 (ADDED); REQ-KNOW-008 (MODIFIED) | | 2026-07-06 | slim-knowledge-l1-l2 | ADDED REQ-KNOW-037(index Description routing-only、單一來源 module-map);MODIFIED REQ-KNOW-011(README 預算 ≤400→≤1000)、REQ-KNOW-013(L1 ≤1,800 per file、L2 ≤1,000、total→per-file 語意對齊)(issue #64) | US-303; REQ-KNOW-037 (ADDED); REQ-KNOW-011, REQ-KNOW-013 (MODIFIED) | | 2026-07-06 | inject-resolved-knowledge-budgets | MODIFIED REQ-KNOW-013(Loading Strategy 預算來源註解指向 `.prospec.yaml` `knowledge.token_budget` + `prospec check knowledge-size`,不再具名 `DEFAULT_KNOWLEDGE_TOKEN_BUDGET`) | REQ-KNOW-013 (MODIFIED) | +| 2026-07-09 | support-file-module-paths | ADDED US-361 + REQ-LIB-029(module-map `paths` stat-based file/dir/glob 分類器 + `moduleScanPatterns`)、REQ-TESTS-050(跨 caller 一致性測試);MODIFIED REQ-KNOW-004(README 掃描經 `moduleScanPatterns` 解讀 paths,修裸資料夾掃 0 檔) | US-361, REQ-LIB-029, REQ-TESTS-050 (ADDED); REQ-KNOW-004 (MODIFIED) | diff --git a/prospec/specs/features/drift-detection.md b/prospec/specs/features/drift-detection.md index 3d1fa0d..c0b137b 100644 --- a/prospec/specs/features/drift-detection.md +++ b/prospec/specs/features/drift-detection.md @@ -1,7 +1,7 @@ --- feature: drift-detection status: active -last_updated: 2026-07-06 +last_updated: 2026-07-09 story_count: 8 req_count: 27 --- @@ -36,6 +36,7 @@ req_count: 27 - WHEN 三類違規任一出現,THEN finding 含 `source_path` + `line`,依(檢項、路徑、行號)codepoint 排序 - WHEN module-map 存在但 schema 不合法,THEN 拋 typed error(fail loudly,不默默換規則集) - WHEN module-map paths 指向 repo 外,THEN 該路徑被 clamp,不驅動掃描或讀檔 +- WHEN module-map paths 條目為單一源碼檔,THEN import-edge 蒐集僅掃描該檔本身(依 `classifyModulePath` 判定 file/dir/glob);非源碼檔條目不產生 import 邊(不再展開為 `/**` 而 ENOTDIR) --- @@ -280,3 +281,4 @@ _(None)_ | 2026-07-06 | enforce-knowledge-size-budget | ADDED US-8(knowledge-size 預算檢查,第 11 個 check id,warn-class)+ REQ-TYPES-060/061、REQ-LIB-027、REQ-SERVICES-065、REQ-TEMPLATES-149、REQ-TESTS-048;MODIFIED REQ-TYPES-052/034(總數→11)+ REQ-TESTS-045(skipped-never-PASS→11 checks);config token_budget 誠實重命名 + DEFAULT_KNOWLEDGE_TOKEN_BUDGET 單一來源(issue #63) | US-8, REQ-TYPES-060, REQ-TYPES-061, REQ-LIB-027, REQ-SERVICES-065, REQ-TEMPLATES-149, REQ-TESTS-048, REQ-TYPES-052, REQ-TYPES-034, REQ-TESTS-045 | | 2026-07-06 | slim-knowledge-l1-l2 | MODIFIED REQ-TYPES-061:`DEFAULT_KNOWLEDGE_TOKEN_BUDGET` 誠實校準 `l1_per_file` 1500→1800、`l2_per_module` 400→1000(warn-class 不變、init seed 同步)(issue #64) | REQ-TYPES-061 (MODIFIED) | | 2026-07-06 | inject-resolved-knowledge-budgets | ADDED REQ-LIB-028(`resolveKnowledgeTokenBudget` 移至 `lib/config` canonical 單一來源、`KnowledgeSizeBudget` 移至 `types/config`);MODIFIED REQ-TYPES-061(單一來源亦餵生成 skill 模板預算渲染)、REQ-SERVICES-065(resolver 改自 `lib/config` import) | REQ-LIB-028 (ADDED); REQ-TYPES-061, REQ-SERVICES-065 (MODIFIED) | +| 2026-07-09 | support-file-module-paths | MODIFIED REQ-LIB-014:import-edge 蒐集依 `classifyModulePath` 處理單一檔案條目(源碼檔→掃該檔、非源碼檔→無邊、修 `/**` ENOTDIR);分類器本體 REQ-LIB-029 落在 ai-knowledge feature | REQ-LIB-014 (MODIFIED) | diff --git a/prospec/specs/product.md b/prospec/specs/product.md index 9416856..46b7267 100644 --- a/prospec/specs/product.md +++ b/prospec/specs/product.md @@ -1,6 +1,6 @@ --- product: prospec -last_updated: 2026-07-08 +last_updated: 2026-07-09 --- # prospec diff --git a/src/lib/drift-sources.ts b/src/lib/drift-sources.ts index 8234b1d..fca2abc 100644 --- a/src/lib/drift-sources.ts +++ b/src/lib/drift-sources.ts @@ -2,7 +2,7 @@ import { existsSync, readFileSync, readdirSync, realpathSync } from 'node:fs'; import { execFileSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import path from 'node:path'; -import { scanDirSync } from './scanner.js'; +import { scanDirSync, classifyModulePath } from './scanner.js'; import { parseYaml } from './yaml-utils.js'; import { parseTaskLine, type TaskKind } from './task-markers.js'; import { @@ -300,7 +300,9 @@ export function collectImportEdges(cwd: string, moduleMap: ModuleMap): ImportEdg // when the glob actually matches files (domain projects relied on the // import-direction check silently degrading to `skipped` before this). if (!isGlob && !existsSync(path.resolve(cwd, prefix))) continue; - const { files } = scanDirSync(importScanPattern(prefix), { cwd }); + const pattern = importScanPattern(prefix, cwd); + if (pattern === null) continue; // non-source file entry — carries no import edges + const { files } = scanDirSync(pattern, { cwd }); if (isGlob && files.length === 0) continue; anyPathExists = true; for (const relPath of files) { @@ -678,12 +680,18 @@ function existsContained(abs: string, cwd: string): boolean { } /** - * Build the file-scan glob for a module path prefix. Supports both literal dir - * prefixes (`src/lib` → `src/lib/**\/*.ext`) and domain globs (`**\/auth/**`, - * `packages/web/**` → `/*.ext`). + * Build the file-scan glob for a module path entry, or `null` when the entry + * carries no scannable source. A single SOURCE file entry (`src/lib/config.ts`) + * is scanned as just itself; a NON-source file entry (`docs/x.md`) yields `null` + * — import edges come only from source, and globbing `/**` would ENOTDIR. + * A literal dir prefix (`src/lib` → `src/lib/**\/*.ext`) expands to its subtree; + * domain globs (`**\/auth/**`, `packages/web/**` → `/*.ext`) are verbatim. */ -function importScanPattern(prefix: string): string { +function importScanPattern(prefix: string, cwd: string): string | null { const EXT = '*.{ts,tsx,mts,cts,js,jsx}'; + if (classifyModulePath(prefix, cwd) === 'file') { + return /\.(?:ts|tsx|mts|cts|js|jsx)$/.test(prefix) ? prefix : null; + } return prefix.endsWith('/**') ? `${prefix}/${EXT}` : `${prefix}/**/${EXT}`; } diff --git a/src/lib/scanner.ts b/src/lib/scanner.ts index 88e6a2a..b4498f9 100644 --- a/src/lib/scanner.ts +++ b/src/lib/scanner.ts @@ -1,5 +1,6 @@ import fg from 'fast-glob'; import { execFile } from 'node:child_process'; +import { statSync } from 'node:fs'; import { promisify } from 'node:util'; import path from 'node:path'; import { ScanError } from '../types/errors.js'; @@ -229,3 +230,41 @@ export function scanDirSync( ); } } + +export type ModulePathKind = 'glob' | 'file' | 'dir' | 'missing'; + +/** + * Classify one `module-map.yaml` `paths` entry by what it points at on disk — + * the single source of truth for how the entry is scanned/attributed. A glob + * (contains `*`) is honored verbatim. An entry that resolves outside `cwd` (same + * lexical containment as `clampModulePaths`) or does not exist is `missing`, so + * callers fall back to literal-prefix behavior rather than fabricating a match. + */ +export function classifyModulePath(rawPath: string, cwd: string): ModulePathKind { + if (rawPath.includes('*')) return 'glob'; + const abs = path.resolve(cwd, rawPath); + const rel = path.relative(cwd, abs); + if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) return 'missing'; + try { + const stat = statSync(abs); + if (stat.isDirectory()) return 'dir'; + if (stat.isFile()) return 'file'; + return 'missing'; + } catch { + return 'missing'; + } +} + +/** + * Map `module-map.yaml` `paths` entries to fast-glob scan patterns with + * file/folder semantics: a directory expands to its subtree, a file scans only + * itself, and a glob or a missing/out-of-repo entry passes through verbatim + * (fast-glob then yields the literal match or nothing). Shared by the knowledge + * README scanners so their `paths` interpretation matches the drift engine's. + */ +export function moduleScanPatterns(paths: string[], cwd: string): string[] { + return paths.map((p) => { + if (classifyModulePath(p, cwd) === 'dir') return `${p.replace(/\/+$/, '')}/**`; + return p; + }); +} diff --git a/src/services/knowledge-update.service.ts b/src/services/knowledge-update.service.ts index b3991f8..1938329 100644 --- a/src/services/knowledge-update.service.ts +++ b/src/services/knowledge-update.service.ts @@ -1,7 +1,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { readConfig, resolveBasePaths } from '../lib/config.js'; -import { scanDir, filterConventions } from '../lib/scanner.js'; +import { scanDir, filterConventions, moduleScanPatterns } from '../lib/scanner.js'; import { renderTemplate } from '../lib/template.js'; import { mergeContent, hasAutoBlock, replaceAutoBlock } from '../lib/content-merger.js'; import { deriveKeyExports } from '../lib/key-exports.js'; @@ -142,7 +142,10 @@ export async function updateModuleReadme( await ensureDir(path.dirname(readmePath)); // Scan module files - const patterns = modulePaths.length > 0 ? modulePaths : [`${moduleName}/**`]; + const patterns = + modulePaths.length > 0 + ? moduleScanPatterns(modulePaths, options.cwd) + : [`${moduleName}/**`]; const scanResult = await scanDir(patterns, { cwd: options.cwd, exclude: options.excludePatterns ?? [], diff --git a/src/services/knowledge.service.ts b/src/services/knowledge.service.ts index ea60a6b..cb8b2a6 100644 --- a/src/services/knowledge.service.ts +++ b/src/services/knowledge.service.ts @@ -1,7 +1,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { readConfig, resolveBasePaths } from '../lib/config.js'; -import { scanDir, filterConventions } from '../lib/scanner.js'; +import { scanDir, filterConventions, moduleScanPatterns } from '../lib/scanner.js'; import { renderTemplate } from '../lib/template.js'; import { mergeContent } from '../lib/content-merger.js'; import { deriveKeyExports } from '../lib/key-exports.js'; @@ -154,7 +154,8 @@ async function scanModules( const moduleInfos: ModuleInfo[] = []; for (const entry of moduleMap.modules) { - const patterns = entry.paths.length > 0 ? entry.paths : [`${entry.name}/**`]; + const patterns = + entry.paths.length > 0 ? moduleScanPatterns(entry.paths, cwd) : [`${entry.name}/**`]; const scanResult = await scanDir(patterns, { cwd, exclude: excludePatterns, diff --git a/src/types/module-map.ts b/src/types/module-map.ts index 7acf5f9..b1466da 100644 --- a/src/types/module-map.ts +++ b/src/types/module-map.ts @@ -12,6 +12,9 @@ const ModuleRelationshipsSchema = z.object({ const ModuleEntrySchema = z.object({ name: z.string(), description: z.string().optional(), + // Each entry is a directory (scanned as its subtree), a single file + // (scanned/attributed as just that file), or a glob (`**/x/**`, `dir/**` — + // honored verbatim). Which one is decided on-disk by classifyModulePath. paths: z.array(z.string()), keywords: z.array(z.string()), // Human-readable localized search aliases (index.md "Aliases" column). Curated diff --git a/tests/contract/mcp-server.test.ts b/tests/contract/mcp-server.test.ts index 26f80c6..b1b4906 100644 --- a/tests/contract/mcp-server.test.ts +++ b/tests/contract/mcp-server.test.ts @@ -418,6 +418,9 @@ describe('knowledge://health (REQ-MCP-004, SC-006)', () => { } }); + // Heaviest case in this file: a real git fixture PLUS a full checkExecute, both + // synchronous git-subprocess work. Under the parallel suite this can exceed the + // 5s default timeout on a loaded machine, so give it explicit headroom. it('matches the knowledge_health section of `prospec check` byte-for-byte (SC-006)', async () => { const ctx = gitFixture(); const client = await connect(ctx); @@ -425,7 +428,7 @@ describe('knowledge://health (REQ-MCP-004, SC-006)', () => { const checkResult = await checkExecute({ cwd: tmpDir }); if (checkResult.kind !== 'report') throw new Error('expected report'); expect(fromResource).toEqual(checkResult.report.structural.knowledge_health); - }); + }, 20000); it('never probes or reports a traversal module name (no existence oracle)', async () => { const ctx = gitFixture(); diff --git a/tests/unit/lib/drift-sources.test.ts b/tests/unit/lib/drift-sources.test.ts index 3cd2d5c..5371053 100644 --- a/tests/unit/lib/drift-sources.test.ts +++ b/tests/unit/lib/drift-sources.test.ts @@ -312,6 +312,54 @@ describe('collectImportEdges', () => { expect(available).toBe(true); expect(edges.map((e) => `${e.from_module}->${e.to_module}`)).toContain('auth->billing'); }); + + it('scans a single-file module path entry (file entries no longer expand to /**)', () => { + const FILE_MAP: ModuleMap = { + modules: [ + { name: 'entry', paths: ['src/lib/entry.ts'], keywords: [], relationships: { depends_on: [] } }, + { name: 'types', paths: ['src/types'], keywords: [], relationships: { depends_on: [] } }, + ], + }; + write('src/lib/entry.ts', "import { X } from '../types/x.js';\n"); + write('src/types/x.ts', ''); + const { available, edges } = collectImportEdges(tmpDir, FILE_MAP); + // Before the fix importScanPattern turned 'src/lib/entry.ts' into + // 'src/lib/entry.ts/**/*.ext' → 0 files → this edge was silently missed and + // a file-only module reported unavailable (skipped). + expect(available).toBe(true); + expect(edges.map((e) => `${e.from_module}->${e.to_module}`)).toContain('entry->types'); + }); + + it('does not scan a non-source file path entry (no spurious edge from its text)', () => { + const DOC_MAP: ModuleMap = { + modules: [ + { name: 'alpha', paths: ['src/alpha/notes.md'], keywords: [], relationships: { depends_on: [] } }, + { name: 'beta', paths: ['src/beta'], keywords: [], relationships: { depends_on: [] } }, + ], + }; + // A non-source file must not be import-scanned at all. The bare import line + // resolves to src/beta/x — a real cross-module target — so pre-fix (which read + // the .md verbatim) emitted a spurious alpha->beta. (Unfenced on purpose: a + // ```ts fence is already blanked by the template-literal stripper.) + write('src/alpha/notes.md', "Usage example:\nimport { x } from '../beta/x.js';\n"); + write('src/beta/x.ts', ''); + const { edges } = collectImportEdges(tmpDir, DOC_MAP); + expect(edges.map((e) => `${e.from_module}->${e.to_module}`)).not.toContain('alpha->beta'); + }); + + it('honors the explicit dir-glob form (`src/x/**`) so existing glob paths keep working', () => { + const GLOB_MAP: ModuleMap = { + modules: [ + { name: 'services', paths: ['src/services/**'], keywords: [], relationships: { depends_on: ['types'] } }, + { name: 'types', paths: ['src/types/**'], keywords: [], relationships: { depends_on: [] } }, + ], + }; + write('src/services/a.ts', "import { X } from '../types/x.js';\n"); + write('src/types/x.ts', ''); + const { available, edges } = collectImportEdges(tmpDir, GLOB_MAP); + expect(available).toBe(true); + expect(edges.map((e) => `${e.from_module}->${e.to_module}`)).toContain('services->types'); + }); }); describe('collectGitTimestamps', () => { diff --git a/tests/unit/lib/scanner.test.ts b/tests/unit/lib/scanner.test.ts index b0219c6..06bf587 100644 --- a/tests/unit/lib/scanner.test.ts +++ b/tests/unit/lib/scanner.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import fg from 'fast-glob'; -import { scanDir, scanDirSync, listGitTrackedFiles, filterConventions } from '../../../src/lib/scanner.js'; +import { scanDir, scanDirSync, listGitTrackedFiles, filterConventions, classifyModulePath, moduleScanPatterns } from '../../../src/lib/scanner.js'; import { CORE_CONVENTIONS } from '../../../src/types/conventions.js'; import { ScanError } from '../../../src/types/errors.js'; @@ -303,3 +303,84 @@ describe('filterConventions (core/demand split, REQ-KNOW-035)', () => { expect(demand).toEqual([]); }); }); + +describe('classifyModulePath', () => { + it('classifies a glob entry (contains *) as glob without touching disk', () => { + // Purely syntactic — no files created. + expect(classifyModulePath('**/auth/**', tmpDir)).toBe('glob'); + expect(classifyModulePath('packages/*/src', tmpDir)).toBe('glob'); + expect(classifyModulePath('src/lib/**', tmpDir)).toBe('glob'); + }); + + it('classifies an existing directory as dir', () => { + createFiles({ 'src/lib/config.ts': '' }); + expect(classifyModulePath('src/lib', tmpDir)).toBe('dir'); + expect(classifyModulePath('src', tmpDir)).toBe('dir'); + }); + + it('classifies an existing file as file', () => { + createFiles({ 'src/lib/config.ts': '' }); + expect(classifyModulePath('src/lib/config.ts', tmpDir)).toBe('file'); + }); + + it('classifies a non-existent path as missing', () => { + expect(classifyModulePath('src/does-not-exist', tmpDir)).toBe('missing'); + expect(classifyModulePath('src/gone.ts', tmpDir)).toBe('missing'); + }); + + it('classifies a repo-escaping path as missing even when the target exists (containment, not existence)', () => { + const outsideName = `escape-${path.basename(tmpDir)}.ts`; + const outsideAbs = path.join(tmpDir, '..', outsideName); + fs.writeFileSync(outsideAbs, ''); + try { + // A real file exists just outside cwd; both the relative and absolute + // spelling must be rejected by the same lexical guard as clampModulePaths. + expect(classifyModulePath(`../${outsideName}`, tmpDir)).toBe('missing'); + expect(classifyModulePath(outsideAbs, tmpDir)).toBe('missing'); + } finally { + fs.rmSync(outsideAbs, { force: true }); + } + }); +}); + +describe('moduleScanPatterns', () => { + it('expands a directory entry to its subtree', () => { + createFiles({ 'src/lib/a.ts': '' }); + expect(moduleScanPatterns(['src/lib'], tmpDir)).toEqual(['src/lib/**']); + }); + + it('strips a trailing slash before expanding a directory', () => { + createFiles({ 'src/lib/a.ts': '' }); + expect(moduleScanPatterns(['src/lib/'], tmpDir)).toEqual(['src/lib/**']); + }); + + it('keeps a file entry verbatim (scans just that file)', () => { + createFiles({ 'src/lib/config.ts': '' }); + expect(moduleScanPatterns(['src/lib/config.ts'], tmpDir)).toEqual(['src/lib/config.ts']); + }); + + it('passes glob and missing entries through verbatim', () => { + expect(moduleScanPatterns(['**/auth/**', 'src/gone'], tmpDir)).toEqual([ + '**/auth/**', + 'src/gone', + ]); + }); + + it('handles a mixed file + dir + glob array', () => { + createFiles({ 'src/lib/config.ts': '', 'src/services/x.ts': '' }); + expect(moduleScanPatterns(['src/lib/config.ts', 'src/services', 'pkg/**'], tmpDir)).toEqual([ + 'src/lib/config.ts', + 'src/services/**', + 'pkg/**', + ]); + }); + + it('the expanded dir pattern actually reaches the subtree via scanDir (fixes bare-dir 0 files)', async () => { + createFiles({ 'src/lib/a.ts': '', 'src/lib/nested/b.ts': '' }); + // Positive control: a bare 'src/lib' entry scans 0 files (the bug this fixes). + const { files: bare } = await scanDir(['src/lib'], { cwd: tmpDir }); + expect(bare).toEqual([]); + const { files } = await scanDir(moduleScanPatterns(['src/lib'], tmpDir), { cwd: tmpDir }); + expect(files).toEqual(['src/lib/a.ts', 'src/lib/nested/b.ts']); + }); +}); diff --git a/tests/unit/services/knowledge-update.service.test.ts b/tests/unit/services/knowledge-update.service.test.ts index 97e85c1..f2c2a90 100644 --- a/tests/unit/services/knowledge-update.service.test.ts +++ b/tests/unit/services/knowledge-update.service.test.ts @@ -35,13 +35,22 @@ vi.mock('../../../src/lib/config.js', () => ({ }), })); -vi.mock('../../../src/lib/scanner.js', () => ({ - scanDir: vi.fn().mockResolvedValue({ - files: ['src/services/foo.service.ts', 'src/services/bar.ts'], - count: 2, - }), - filterConventions: vi.fn().mockReturnValue({ core: [], demand: [] }), -})); +// Partial mock: keep the REAL moduleScanPatterns/classifyModulePath (so the +// path→scan-pattern wiring is exercised for real against memfs) and stub only +// the fast-glob-backed scanDir + filterConventions. +vi.mock('../../../src/lib/scanner.js', async () => { + const actual = await vi.importActual( + '../../../src/lib/scanner.js', + ); + return { + ...actual, + scanDir: vi.fn().mockResolvedValue({ + files: ['src/services/foo.service.ts', 'src/services/bar.ts'], + count: 2, + }), + filterConventions: vi.fn().mockReturnValue({ core: [], demand: [] }), + }; +}); // README renders stay canned, but the knowledge index templates render for REAL // (via a Handlebars instance fed from the actual template files, read with the @@ -293,6 +302,23 @@ describe('updateModuleReadme', () => { expect(context.path).toBe('auth'); }); + it('routes a directory module path through moduleScanPatterns (scans dir/** not the bare dir)', async () => { + const { scanDir } = await import('../../../src/lib/scanner.js'); + vol.fromJSON({ '/project/src/realmod/index.ts': 'export const x = 1;\n' }); + vol.mkdirSync('/project/prospec/ai-knowledge/modules', { recursive: true }); + + await updateModuleReadme('realmod', ['src/realmod'], { + cwd: '/project', + knowledgeBasePath: 'prospec/ai-knowledge', + }); + + // A bare 'src/realmod' entry scans 0 files under fast-glob onlyFiles; the fix + // must expand it to 'src/realmod/**' before scanDir. Reverting the service to + // raw modulePaths makes scanDir receive ['src/realmod'] → this goes red. + const scanArgs = vi.mocked(scanDir).mock.calls.at(-1)!; + expect(scanArgs[0]).toEqual(['src/realmod/**']); + }); + it('infers per-extension and per-suffix file descriptions into key_files (L548/L551-554/L565)', async () => { // key_files is capped at the first 10 scanned files, so split the branch // matrix across two render calls and assert the distinguishing description diff --git a/tests/unit/services/knowledge.service.test.ts b/tests/unit/services/knowledge.service.test.ts index d12f062..ee7189c 100644 --- a/tests/unit/services/knowledge.service.test.ts +++ b/tests/unit/services/knowledge.service.test.ts @@ -93,6 +93,35 @@ knowledge: expect(result.generatedFiles.length).toBeGreaterThan(0); }); + it('routes a bare directory module path through moduleScanPatterns before scanning (getModuleInfos)', async () => { + const fg = (await import('fast-glob')).default; + vol.fromJSON({ + '/project/.prospec.yaml': `project: + name: test +knowledge: + base_path: prospec/ai-knowledge +`, + // Bare directory entry (no /** suffix) — the case that scanned 0 files before. + '/project/prospec/ai-knowledge/module-map.yaml': `modules: + - name: services + description: Business logic + paths: + - src/services + keywords: + - services +`, + '/project/src/services/auth.ts': '', + }); + + await execute({ cwd: '/project' }); + + const patternArgs = vi.mocked(fg.glob).mock.calls.map((c) => c[0]); + // The bare 'src/services' entry must reach fast-glob expanded to 'src/services/**'; + // reverting getModuleInfos to raw entry.paths makes it scan bare 'src/services' (0 files). + expect(patternArgs).toContainEqual(['src/services/**']); + expect(patternArgs).not.toContainEqual(['src/services']); + }); + it('should call renderTemplate with key_exports in context', async () => { const { renderTemplate: mockRender } = await import('../../../src/lib/template.js');