@ktpartners/dgs-platform 2.6.2

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.
Files changed (256) hide show
  1. package/LICENSE +38 -0
  2. package/README.md +851 -0
  3. package/agents/dgs-codebase-cross-analyzer.md +183 -0
  4. package/agents/dgs-codebase-mapper.md +782 -0
  5. package/agents/dgs-codebase-synthesizer.md +156 -0
  6. package/agents/dgs-debugger.md +1256 -0
  7. package/agents/dgs-executor.md +550 -0
  8. package/agents/dgs-integration-checker.md +481 -0
  9. package/agents/dgs-nyquist-auditor.md +178 -0
  10. package/agents/dgs-phase-researcher.md +563 -0
  11. package/agents/dgs-phase-verifier.md +450 -0
  12. package/agents/dgs-plan-checker.md +708 -0
  13. package/agents/dgs-planner.md +1324 -0
  14. package/agents/dgs-project-researcher.md +631 -0
  15. package/agents/dgs-research-synthesizer.md +249 -0
  16. package/agents/dgs-roadmapper.md +652 -0
  17. package/agents/dgs-verifier.md +607 -0
  18. package/bin/install.js +2073 -0
  19. package/commands/dgs/add-doc.md +45 -0
  20. package/commands/dgs/add-idea.md +38 -0
  21. package/commands/dgs/add-phase.md +43 -0
  22. package/commands/dgs/add-repo.md +54 -0
  23. package/commands/dgs/add-tests.md +41 -0
  24. package/commands/dgs/add-todo.md +47 -0
  25. package/commands/dgs/approve-spec.md +38 -0
  26. package/commands/dgs/audit-milestone.md +36 -0
  27. package/commands/dgs/audit-phase.md +37 -0
  28. package/commands/dgs/cancel-job.md +23 -0
  29. package/commands/dgs/capture-principle.md +143 -0
  30. package/commands/dgs/check-todos.md +45 -0
  31. package/commands/dgs/cleanup.md +18 -0
  32. package/commands/dgs/complete-milestone.md +136 -0
  33. package/commands/dgs/complete-project.md +70 -0
  34. package/commands/dgs/consolidate-ideas.md +50 -0
  35. package/commands/dgs/create-milestone-job.md +37 -0
  36. package/commands/dgs/debug.md +164 -0
  37. package/commands/dgs/develop-idea.md +53 -0
  38. package/commands/dgs/discuss-idea.md +41 -0
  39. package/commands/dgs/discuss-phase.md +83 -0
  40. package/commands/dgs/execute-phase.md +41 -0
  41. package/commands/dgs/fast.md +38 -0
  42. package/commands/dgs/find-related-ideas.md +43 -0
  43. package/commands/dgs/health.md +28 -0
  44. package/commands/dgs/help.md +22 -0
  45. package/commands/dgs/import-spec.md +36 -0
  46. package/commands/dgs/init-product.md +28 -0
  47. package/commands/dgs/insert-phase.md +32 -0
  48. package/commands/dgs/join-discord.md +18 -0
  49. package/commands/dgs/list-docs.md +40 -0
  50. package/commands/dgs/list-ideas.md +42 -0
  51. package/commands/dgs/list-jobs.md +22 -0
  52. package/commands/dgs/list-phase-assumptions.md +46 -0
  53. package/commands/dgs/list-projects.md +57 -0
  54. package/commands/dgs/list-specs.md +40 -0
  55. package/commands/dgs/map-codebase.md +92 -0
  56. package/commands/dgs/new-milestone.md +44 -0
  57. package/commands/dgs/new-project.md +42 -0
  58. package/commands/dgs/node-repair.md +26 -0
  59. package/commands/dgs/overlap-check.md +20 -0
  60. package/commands/dgs/pause-work.md +38 -0
  61. package/commands/dgs/plan-milestone-gaps.md +34 -0
  62. package/commands/dgs/plan-phase.md +44 -0
  63. package/commands/dgs/progress.md +24 -0
  64. package/commands/dgs/quick.md +41 -0
  65. package/commands/dgs/reactivate-project.md +70 -0
  66. package/commands/dgs/reapply-patches.md +110 -0
  67. package/commands/dgs/refine-spec.md +38 -0
  68. package/commands/dgs/reject-idea.md +43 -0
  69. package/commands/dgs/remove-doc.md +44 -0
  70. package/commands/dgs/remove-phase.md +31 -0
  71. package/commands/dgs/remove-repo.md +69 -0
  72. package/commands/dgs/research-idea.md +43 -0
  73. package/commands/dgs/research-phase.md +189 -0
  74. package/commands/dgs/restore-idea.md +45 -0
  75. package/commands/dgs/resume-work.md +40 -0
  76. package/commands/dgs/rollback-job.md +24 -0
  77. package/commands/dgs/run-job.md +35 -0
  78. package/commands/dgs/search.md +40 -0
  79. package/commands/dgs/set-profile.md +34 -0
  80. package/commands/dgs/settings.md +38 -0
  81. package/commands/dgs/switch-project.md +58 -0
  82. package/commands/dgs/undo-consolidation.md +42 -0
  83. package/commands/dgs/update-idea.md +44 -0
  84. package/commands/dgs/update.md +37 -0
  85. package/commands/dgs/validate-phase.md +35 -0
  86. package/commands/dgs/verify-work.md +39 -0
  87. package/commands/dgs/write-spec.md +49 -0
  88. package/deliver-great-systems/.planning/phases/09-backend-wiring-and-error-handling/09-01-SUMMARY.md +84 -0
  89. package/deliver-great-systems/.planning/phases/09-backend-wiring-and-error-handling/09-02-SUMMARY.md +86 -0
  90. package/deliver-great-systems/.planning/phases/10-v1-to-v2-migration-flow/10-01-SUMMARY.md +85 -0
  91. package/deliver-great-systems/bin/dgs-tools.cjs +1444 -0
  92. package/deliver-great-systems/bin/lib/auto-test.cjs +1365 -0
  93. package/deliver-great-systems/bin/lib/commands.cjs +570 -0
  94. package/deliver-great-systems/bin/lib/config.cjs +417 -0
  95. package/deliver-great-systems/bin/lib/conflict-agent.cjs +1063 -0
  96. package/deliver-great-systems/bin/lib/conflict-agent.test.cjs +554 -0
  97. package/deliver-great-systems/bin/lib/context.cjs +929 -0
  98. package/deliver-great-systems/bin/lib/context.test.cjs +693 -0
  99. package/deliver-great-systems/bin/lib/core.cjs +744 -0
  100. package/deliver-great-systems/bin/lib/core.test.cjs +822 -0
  101. package/deliver-great-systems/bin/lib/docs.cjs +919 -0
  102. package/deliver-great-systems/bin/lib/docs.test.cjs +211 -0
  103. package/deliver-great-systems/bin/lib/execution.cjs +705 -0
  104. package/deliver-great-systems/bin/lib/execution.test.cjs +1472 -0
  105. package/deliver-great-systems/bin/lib/frontmatter.cjs +324 -0
  106. package/deliver-great-systems/bin/lib/ideas.cjs +1406 -0
  107. package/deliver-great-systems/bin/lib/ideas.test.cjs +1417 -0
  108. package/deliver-great-systems/bin/lib/identity.cjs +125 -0
  109. package/deliver-great-systems/bin/lib/init.cjs +1114 -0
  110. package/deliver-great-systems/bin/lib/init.test.cjs +1271 -0
  111. package/deliver-great-systems/bin/lib/jobs.cjs +2015 -0
  112. package/deliver-great-systems/bin/lib/jobs.test.cjs +2619 -0
  113. package/deliver-great-systems/bin/lib/merge-conflicts.cjs +654 -0
  114. package/deliver-great-systems/bin/lib/merge-conflicts.test.cjs +370 -0
  115. package/deliver-great-systems/bin/lib/migration.cjs +352 -0
  116. package/deliver-great-systems/bin/lib/migration.test.cjs +582 -0
  117. package/deliver-great-systems/bin/lib/milestone.cjs +243 -0
  118. package/deliver-great-systems/bin/lib/overlap.cjs +437 -0
  119. package/deliver-great-systems/bin/lib/overlap.test.cjs +747 -0
  120. package/deliver-great-systems/bin/lib/path-audit.test.cjs +384 -0
  121. package/deliver-great-systems/bin/lib/paths.cjs +144 -0
  122. package/deliver-great-systems/bin/lib/paths.test.cjs +486 -0
  123. package/deliver-great-systems/bin/lib/phase.cjs +910 -0
  124. package/deliver-great-systems/bin/lib/projects.cjs +691 -0
  125. package/deliver-great-systems/bin/lib/projects.test.cjs +871 -0
  126. package/deliver-great-systems/bin/lib/repos.cjs +1432 -0
  127. package/deliver-great-systems/bin/lib/repos.test.cjs +1882 -0
  128. package/deliver-great-systems/bin/lib/roadmap.cjs +305 -0
  129. package/deliver-great-systems/bin/lib/search.cjs +570 -0
  130. package/deliver-great-systems/bin/lib/specs.cjs +1303 -0
  131. package/deliver-great-systems/bin/lib/state.cjs +893 -0
  132. package/deliver-great-systems/bin/lib/template.cjs +228 -0
  133. package/deliver-great-systems/bin/lib/test-helpers.cjs +291 -0
  134. package/deliver-great-systems/bin/lib/verify.cjs +796 -0
  135. package/deliver-great-systems/references/checkpoints.md +776 -0
  136. package/deliver-great-systems/references/conflict-resolution.md +66 -0
  137. package/deliver-great-systems/references/context-tiers.md +166 -0
  138. package/deliver-great-systems/references/continuation-format.md +249 -0
  139. package/deliver-great-systems/references/decimal-phase-calculation.md +67 -0
  140. package/deliver-great-systems/references/git-integration.md +250 -0
  141. package/deliver-great-systems/references/git-planning-commit.md +40 -0
  142. package/deliver-great-systems/references/model-profile-resolution.md +36 -0
  143. package/deliver-great-systems/references/model-profiles.md +95 -0
  144. package/deliver-great-systems/references/phase-argument-parsing.md +61 -0
  145. package/deliver-great-systems/references/planning-config.md +224 -0
  146. package/deliver-great-systems/references/questioning.md +162 -0
  147. package/deliver-great-systems/references/spec-review-loop.md +177 -0
  148. package/deliver-great-systems/references/tdd.md +265 -0
  149. package/deliver-great-systems/references/ui-brand.md +160 -0
  150. package/deliver-great-systems/references/verification-patterns.md +612 -0
  151. package/deliver-great-systems/templates/DEBUG.md +166 -0
  152. package/deliver-great-systems/templates/UAT.md +251 -0
  153. package/deliver-great-systems/templates/VALIDATION.md +95 -0
  154. package/deliver-great-systems/templates/claude-md.md +74 -0
  155. package/deliver-great-systems/templates/codebase/architecture.md +257 -0
  156. package/deliver-great-systems/templates/codebase/concerns.md +312 -0
  157. package/deliver-great-systems/templates/codebase/conventions.md +309 -0
  158. package/deliver-great-systems/templates/codebase/integrations.md +282 -0
  159. package/deliver-great-systems/templates/codebase/stack.md +188 -0
  160. package/deliver-great-systems/templates/codebase/structure.md +287 -0
  161. package/deliver-great-systems/templates/codebase/testing.md +482 -0
  162. package/deliver-great-systems/templates/config.json +38 -0
  163. package/deliver-great-systems/templates/context.md +354 -0
  164. package/deliver-great-systems/templates/continue-here.md +80 -0
  165. package/deliver-great-systems/templates/debug-subagent-prompt.md +93 -0
  166. package/deliver-great-systems/templates/discovery.md +148 -0
  167. package/deliver-great-systems/templates/milestone-archive.md +125 -0
  168. package/deliver-great-systems/templates/milestone.md +117 -0
  169. package/deliver-great-systems/templates/phase-prompt.md +615 -0
  170. package/deliver-great-systems/templates/planner-subagent-prompt.md +119 -0
  171. package/deliver-great-systems/templates/project.md +186 -0
  172. package/deliver-great-systems/templates/requirements.md +233 -0
  173. package/deliver-great-systems/templates/research-project/ARCHITECTURE.md +206 -0
  174. package/deliver-great-systems/templates/research-project/FEATURES.md +149 -0
  175. package/deliver-great-systems/templates/research-project/PITFALLS.md +202 -0
  176. package/deliver-great-systems/templates/research-project/STACK.md +122 -0
  177. package/deliver-great-systems/templates/research-project/SUMMARY.md +172 -0
  178. package/deliver-great-systems/templates/research.md +554 -0
  179. package/deliver-great-systems/templates/retrospective.md +54 -0
  180. package/deliver-great-systems/templates/roadmap.md +204 -0
  181. package/deliver-great-systems/templates/state.md +178 -0
  182. package/deliver-great-systems/templates/summary-complex.md +59 -0
  183. package/deliver-great-systems/templates/summary-minimal.md +41 -0
  184. package/deliver-great-systems/templates/summary-standard.md +48 -0
  185. package/deliver-great-systems/templates/summary.md +253 -0
  186. package/deliver-great-systems/templates/user-setup.md +313 -0
  187. package/deliver-great-systems/templates/verification-report.md +324 -0
  188. package/deliver-great-systems/workflows/add-doc.md +151 -0
  189. package/deliver-great-systems/workflows/add-idea.md +96 -0
  190. package/deliver-great-systems/workflows/add-phase.md +120 -0
  191. package/deliver-great-systems/workflows/add-tests.md +359 -0
  192. package/deliver-great-systems/workflows/add-todo.md +162 -0
  193. package/deliver-great-systems/workflows/approve-spec.md +194 -0
  194. package/deliver-great-systems/workflows/audit-milestone.md +364 -0
  195. package/deliver-great-systems/workflows/audit-phase.md +462 -0
  196. package/deliver-great-systems/workflows/cancel-job.md +108 -0
  197. package/deliver-great-systems/workflows/check-todos.md +181 -0
  198. package/deliver-great-systems/workflows/cleanup.md +247 -0
  199. package/deliver-great-systems/workflows/codereview.md +526 -0
  200. package/deliver-great-systems/workflows/complete-milestone.md +1298 -0
  201. package/deliver-great-systems/workflows/consolidate-ideas.md +365 -0
  202. package/deliver-great-systems/workflows/create-milestone-job.md +177 -0
  203. package/deliver-great-systems/workflows/develop-idea.md +544 -0
  204. package/deliver-great-systems/workflows/diagnose-issues.md +231 -0
  205. package/deliver-great-systems/workflows/discovery-phase.md +301 -0
  206. package/deliver-great-systems/workflows/discuss-idea.md +263 -0
  207. package/deliver-great-systems/workflows/discuss-phase.md +733 -0
  208. package/deliver-great-systems/workflows/execute-phase.md +571 -0
  209. package/deliver-great-systems/workflows/execute-plan.md +592 -0
  210. package/deliver-great-systems/workflows/find-related-ideas.md +271 -0
  211. package/deliver-great-systems/workflows/health.md +173 -0
  212. package/deliver-great-systems/workflows/help.md +997 -0
  213. package/deliver-great-systems/workflows/import-spec.md +381 -0
  214. package/deliver-great-systems/workflows/init-product.md +767 -0
  215. package/deliver-great-systems/workflows/insert-phase.md +138 -0
  216. package/deliver-great-systems/workflows/list-docs.md +119 -0
  217. package/deliver-great-systems/workflows/list-ideas.md +154 -0
  218. package/deliver-great-systems/workflows/list-jobs.md +89 -0
  219. package/deliver-great-systems/workflows/list-phase-assumptions.md +192 -0
  220. package/deliver-great-systems/workflows/list-specs.md +101 -0
  221. package/deliver-great-systems/workflows/map-codebase.md +621 -0
  222. package/deliver-great-systems/workflows/new-milestone.md +591 -0
  223. package/deliver-great-systems/workflows/new-project.md +1113 -0
  224. package/deliver-great-systems/workflows/node-repair.md +94 -0
  225. package/deliver-great-systems/workflows/overlap-check.md +86 -0
  226. package/deliver-great-systems/workflows/pause-work.md +134 -0
  227. package/deliver-great-systems/workflows/plan-milestone-gaps.md +306 -0
  228. package/deliver-great-systems/workflows/plan-phase.md +698 -0
  229. package/deliver-great-systems/workflows/progress.md +386 -0
  230. package/deliver-great-systems/workflows/quick.md +845 -0
  231. package/deliver-great-systems/workflows/refine-spec.md +275 -0
  232. package/deliver-great-systems/workflows/reject-idea.md +109 -0
  233. package/deliver-great-systems/workflows/remove-doc.md +117 -0
  234. package/deliver-great-systems/workflows/remove-phase.md +163 -0
  235. package/deliver-great-systems/workflows/research-idea.md +325 -0
  236. package/deliver-great-systems/workflows/research-phase.md +81 -0
  237. package/deliver-great-systems/workflows/restore-idea.md +101 -0
  238. package/deliver-great-systems/workflows/resume-project.md +311 -0
  239. package/deliver-great-systems/workflows/rollback-job.md +130 -0
  240. package/deliver-great-systems/workflows/run-job.md +498 -0
  241. package/deliver-great-systems/workflows/search.md +130 -0
  242. package/deliver-great-systems/workflows/set-profile.md +83 -0
  243. package/deliver-great-systems/workflows/settings.md +470 -0
  244. package/deliver-great-systems/workflows/transition.md +563 -0
  245. package/deliver-great-systems/workflows/undo-consolidation.md +155 -0
  246. package/deliver-great-systems/workflows/update-idea.md +157 -0
  247. package/deliver-great-systems/workflows/update.md +242 -0
  248. package/deliver-great-systems/workflows/validate-phase.md +177 -0
  249. package/deliver-great-systems/workflows/verify-phase.md +253 -0
  250. package/deliver-great-systems/workflows/verify-work.md +671 -0
  251. package/deliver-great-systems/workflows/write-spec.md +450 -0
  252. package/hooks/dist/dgs-check-update.js +62 -0
  253. package/hooks/dist/dgs-context-monitor.js +141 -0
  254. package/hooks/dist/dgs-statusline.js +115 -0
  255. package/package.json +60 -0
  256. package/scripts/build-hooks.js +43 -0
@@ -0,0 +1,1882 @@
1
+ /**
2
+ * Tests for repos.cjs — REPOS.md management, prefix-match resolution, discovery, .gitignore sync
3
+ */
4
+
5
+ const { describe, it, beforeEach, afterEach } = require('node:test');
6
+ const assert = require('node:assert');
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const os = require('os');
10
+
11
+ const { createTempDir, cleanupDir } = require('./test-helpers.cjs');
12
+
13
+ // Helper to create .planning/REPOS.md with given content
14
+ function writeReposFile(cwd, content) {
15
+ const planningDir = path.join(cwd, '.planning');
16
+ fs.mkdirSync(planningDir, { recursive: true });
17
+ fs.writeFileSync(path.join(planningDir, 'REPOS.md'), content);
18
+ }
19
+
20
+ const VALID_REPOS_MD = `# Repos
21
+
22
+ Registered repositories for this product. Managed by DGS — manual edits may be overwritten.
23
+
24
+ | Name | Path | GitHub URL | Description |
25
+ |------|------|------------|-------------|
26
+ | web-app | ./web-app | https://github.com/org/web-app | React frontend |
27
+ | server | ./server | https://github.com/org/server | Node.js API |
28
+ | shared-lib | ./shared-lib | | Shared TypeScript types and utilities |
29
+ `;
30
+
31
+ const {
32
+ parseReposMd,
33
+ writeReposMd,
34
+ validateReposMdEager,
35
+ resolveFileToRepo,
36
+ discoverRepos,
37
+ discoverSiblingRepos,
38
+ syncGitignore,
39
+ validateRepoPaths,
40
+ hasPathConflict,
41
+ writeProjectsMd,
42
+ } = require('./repos.cjs');
43
+
44
+ // ─── parseReposMd ────────────────────────────────────────────────────────────
45
+
46
+ describe('parseReposMd', () => {
47
+ let tmpDir;
48
+ beforeEach(() => { tmpDir = createTempDir(); });
49
+ afterEach(() => { cleanupDir(tmpDir); });
50
+
51
+ it('returns structured array from valid REPOS.md', () => {
52
+ writeReposFile(tmpDir, VALID_REPOS_MD);
53
+ const result = parseReposMd(tmpDir);
54
+ assert.ok(result);
55
+ assert.strictEqual(result.repos.length, 3);
56
+ assert.strictEqual(result.repos[0].name, 'web-app');
57
+ assert.strictEqual(result.repos[0].path, './web-app');
58
+ assert.strictEqual(result.repos[0].url, 'https://github.com/org/web-app');
59
+ assert.strictEqual(result.repos[0].description, 'React frontend');
60
+ });
61
+
62
+ it('returns null when file does not exist', () => {
63
+ fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
64
+ const result = parseReposMd(tmpDir);
65
+ assert.strictEqual(result, null);
66
+ });
67
+
68
+ it('returns null when file does not start with # Repos', () => {
69
+ writeReposFile(tmpDir, '# Something Else\n\nRandom content');
70
+ const result = parseReposMd(tmpDir);
71
+ assert.strictEqual(result, null);
72
+ });
73
+
74
+ it('returns empty repos when header exists but table is empty', () => {
75
+ writeReposFile(tmpDir, `# Repos
76
+
77
+ Registered repositories.
78
+
79
+ | Name | Path | GitHub URL | Description |
80
+ |------|------|------------|-------------|
81
+ `);
82
+ const result = parseReposMd(tmpDir);
83
+ assert.ok(result);
84
+ assert.strictEqual(result.repos.length, 0);
85
+ });
86
+
87
+ it('correctly parses all 4 columns', () => {
88
+ writeReposFile(tmpDir, VALID_REPOS_MD);
89
+ const result = parseReposMd(tmpDir);
90
+ const server = result.repos[1];
91
+ assert.strictEqual(server.name, 'server');
92
+ assert.strictEqual(server.path, './server');
93
+ assert.strictEqual(server.url, 'https://github.com/org/server');
94
+ assert.strictEqual(server.description, 'Node.js API');
95
+ });
96
+
97
+ it('handles blank GitHub URL', () => {
98
+ writeReposFile(tmpDir, VALID_REPOS_MD);
99
+ const result = parseReposMd(tmpDir);
100
+ const lib = result.repos[2];
101
+ assert.strictEqual(lib.name, 'shared-lib');
102
+ assert.strictEqual(lib.url, '');
103
+ });
104
+
105
+ it('handles blank Description', () => {
106
+ writeReposFile(tmpDir, `# Repos
107
+
108
+ | Name | Path | GitHub URL | Description |
109
+ |------|------|------------|-------------|
110
+ | minimal | ./minimal | | |
111
+ `);
112
+ const result = parseReposMd(tmpDir);
113
+ assert.strictEqual(result.repos[0].description, '');
114
+ });
115
+
116
+ it('handles extra whitespace in cells', () => {
117
+ writeReposFile(tmpDir, `# Repos
118
+
119
+ | Name | Path | GitHub URL | Description |
120
+ |------|------|------------|-------------|
121
+ | spacey | ./spacey | https://github.com/x | Has spaces |
122
+ `);
123
+ const result = parseReposMd(tmpDir);
124
+ assert.strictEqual(result.repos[0].name, 'spacey');
125
+ assert.strictEqual(result.repos[0].path, './spacey');
126
+ assert.strictEqual(result.repos[0].url, 'https://github.com/x');
127
+ assert.strictEqual(result.repos[0].description, 'Has spaces');
128
+ });
129
+
130
+ it('handles malformed table rows with fewer than 2 columns', () => {
131
+ writeReposFile(tmpDir, `# Repos
132
+
133
+ | Name | Path | GitHub URL | Description |
134
+ |------|------|------------|-------------|
135
+ | only-one |
136
+ | good | ./good | https://github.com/x | OK |
137
+ `);
138
+ const result = parseReposMd(tmpDir);
139
+ // Malformed row should be skipped, good row kept
140
+ assert.ok(result.repos.length >= 1);
141
+ const goodRepo = result.repos.find(r => r.name === 'good');
142
+ assert.ok(goodRepo);
143
+ });
144
+ });
145
+
146
+ // ─── writeReposMd ────────────────────────────────────────────────────────────
147
+
148
+ describe('writeReposMd', () => {
149
+ let tmpDir;
150
+ beforeEach(() => { tmpDir = createTempDir(); fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true }); });
151
+ afterEach(() => { cleanupDir(tmpDir); });
152
+
153
+ it('creates file with # Repos header', () => {
154
+ writeReposMd(tmpDir, [{ name: 'app', path: './app', url: '', description: 'App' }]);
155
+ const content = fs.readFileSync(path.join(tmpDir, '.planning', 'REPOS.md'), 'utf-8');
156
+ assert.ok(content.startsWith('# Repos'));
157
+ });
158
+
159
+ it('includes explanatory text', () => {
160
+ writeReposMd(tmpDir, []);
161
+ const content = fs.readFileSync(path.join(tmpDir, '.planning', 'REPOS.md'), 'utf-8');
162
+ assert.ok(content.includes('Managed by DGS'));
163
+ });
164
+
165
+ it('writes properly formatted markdown table', () => {
166
+ writeReposMd(tmpDir, [
167
+ { name: 'web', path: './web', url: 'https://github.com/org/web', description: 'Frontend' },
168
+ ]);
169
+ const content = fs.readFileSync(path.join(tmpDir, '.planning', 'REPOS.md'), 'utf-8');
170
+ assert.ok(content.includes('| Name | Path | GitHub URL | Description |'));
171
+ assert.ok(content.includes('|------|------|------------|-------------|'));
172
+ assert.ok(content.includes('| web | ./web | https://github.com/org/web | Frontend |'));
173
+ });
174
+
175
+ it('handles empty repos array', () => {
176
+ writeReposMd(tmpDir, []);
177
+ const content = fs.readFileSync(path.join(tmpDir, '.planning', 'REPOS.md'), 'utf-8');
178
+ assert.ok(content.includes('| Name | Path | GitHub URL | Description |'));
179
+ // Should NOT have any data rows
180
+ const lines = content.split('\n').filter(l => l.startsWith('|'));
181
+ assert.strictEqual(lines.length, 2); // header + separator only
182
+ });
183
+
184
+ it('writes to .planning/REPOS.md', () => {
185
+ writeReposMd(tmpDir, []);
186
+ assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'REPOS.md')));
187
+ });
188
+ });
189
+
190
+ // ─── resolveFileToRepo ───────────────────────────────────────────────────────
191
+
192
+ describe('resolveFileToRepo', () => {
193
+ const repos = [
194
+ { name: 'web', path: './web' },
195
+ { name: 'web-app', path: './web-app' },
196
+ { name: 'services-api', path: './services/api' },
197
+ { name: 'services-api-gateway', path: './services/api-gateway' },
198
+ { name: 'server', path: './server' },
199
+ ];
200
+
201
+ it('matches web-app/src/App.tsx to web-app, NOT web', () => {
202
+ const result = resolveFileToRepo('web-app/src/App.tsx', repos);
203
+ assert.ok(result);
204
+ assert.strictEqual(result.name, 'web-app');
205
+ });
206
+
207
+ it('matches web/index.html to web', () => {
208
+ const result = resolveFileToRepo('web/index.html', repos);
209
+ assert.ok(result);
210
+ assert.strictEqual(result.name, 'web');
211
+ });
212
+
213
+ it('matches services/api/src/index.ts to services-api, NOT services-api-gateway', () => {
214
+ const result = resolveFileToRepo('services/api/src/index.ts', repos);
215
+ assert.ok(result);
216
+ assert.strictEqual(result.name, 'services-api');
217
+ });
218
+
219
+ it('matches services/api-gateway/routes.ts to services-api-gateway', () => {
220
+ const result = resolveFileToRepo('services/api-gateway/routes.ts', repos);
221
+ assert.ok(result);
222
+ assert.strictEqual(result.name, 'services-api-gateway');
223
+ });
224
+
225
+ it('returns null for CLAUDE.md (product folder file)', () => {
226
+ const result = resolveFileToRepo('CLAUDE.md', repos);
227
+ assert.strictEqual(result, null);
228
+ });
229
+
230
+ it('returns null for .planning/STATE.md', () => {
231
+ const result = resolveFileToRepo('.planning/STATE.md', repos);
232
+ assert.strictEqual(result, null);
233
+ });
234
+
235
+ it('handles paths with leading ./', () => {
236
+ const result = resolveFileToRepo('./web-app/src/App.tsx', repos);
237
+ assert.ok(result);
238
+ assert.strictEqual(result.name, 'web-app');
239
+ });
240
+
241
+ it('handles repo paths without leading ./', () => {
242
+ const reposNoPrefix = [{ name: 'server', path: 'server' }];
243
+ const result = resolveFileToRepo('server/src/index.ts', reposNoPrefix);
244
+ assert.ok(result);
245
+ assert.strictEqual(result.name, 'server');
246
+ });
247
+
248
+ it('works with single repo', () => {
249
+ const singleRepo = [{ name: 'app', path: './app' }];
250
+ const result = resolveFileToRepo('app/main.js', singleRepo);
251
+ assert.ok(result);
252
+ assert.strictEqual(result.name, 'app');
253
+ });
254
+
255
+ it('returns name and path of matched repo', () => {
256
+ const result = resolveFileToRepo('server/api.ts', repos);
257
+ assert.ok(result);
258
+ assert.strictEqual(result.name, 'server');
259
+ assert.strictEqual(result.path, './server');
260
+ });
261
+
262
+ it('resolves deeply nested paths correctly', () => {
263
+ const result = resolveFileToRepo('services/api/src/controllers/users/index.ts', repos);
264
+ assert.ok(result);
265
+ assert.strictEqual(result.name, 'services-api');
266
+ });
267
+
268
+ it('returns null for empty repos array', () => {
269
+ const result = resolveFileToRepo('anything/file.ts', []);
270
+ assert.strictEqual(result, null);
271
+ });
272
+
273
+ it('handles undefined/null filePath gracefully', () => {
274
+ assert.strictEqual(resolveFileToRepo(null, repos), null);
275
+ assert.strictEqual(resolveFileToRepo(undefined, repos), null);
276
+ assert.strictEqual(resolveFileToRepo('', repos), null);
277
+ });
278
+ });
279
+
280
+ // ─── discoverRepos ───────────────────────────────────────────────────────────
281
+
282
+ describe('discoverRepos', () => {
283
+ let tmpDir;
284
+ beforeEach(() => { tmpDir = createTempDir(); });
285
+ afterEach(() => { cleanupDir(tmpDir); });
286
+
287
+ it('finds directories containing .git/', () => {
288
+ fs.mkdirSync(path.join(tmpDir, 'my-repo', '.git'), { recursive: true });
289
+ const result = discoverRepos(tmpDir);
290
+ assert.strictEqual(result.length, 1);
291
+ assert.strictEqual(result[0].name, 'my-repo');
292
+ });
293
+
294
+ it('excludes node_modules', () => {
295
+ fs.mkdirSync(path.join(tmpDir, 'node_modules', '.git'), { recursive: true });
296
+ fs.mkdirSync(path.join(tmpDir, 'real-repo', '.git'), { recursive: true });
297
+ const result = discoverRepos(tmpDir);
298
+ assert.strictEqual(result.length, 1);
299
+ assert.strictEqual(result[0].name, 'real-repo');
300
+ });
301
+
302
+ it('excludes .planning', () => {
303
+ fs.mkdirSync(path.join(tmpDir, '.planning', '.git'), { recursive: true });
304
+ const result = discoverRepos(tmpDir);
305
+ assert.strictEqual(result.length, 0);
306
+ });
307
+
308
+ it('excludes dot-directories', () => {
309
+ fs.mkdirSync(path.join(tmpDir, '.hidden', '.git'), { recursive: true });
310
+ const result = discoverRepos(tmpDir);
311
+ assert.strictEqual(result.length, 0);
312
+ });
313
+
314
+ it('excludes non-directories', () => {
315
+ fs.writeFileSync(path.join(tmpDir, 'file.txt'), 'hello');
316
+ const result = discoverRepos(tmpDir);
317
+ assert.strictEqual(result.length, 0);
318
+ });
319
+
320
+ it('extracts description from package.json', () => {
321
+ const repoDir = path.join(tmpDir, 'pkg-repo');
322
+ fs.mkdirSync(path.join(repoDir, '.git'), { recursive: true });
323
+ fs.writeFileSync(path.join(repoDir, 'package.json'), JSON.stringify({ description: 'A test package' }));
324
+ const result = discoverRepos(tmpDir);
325
+ assert.strictEqual(result[0].description, 'A test package');
326
+ });
327
+
328
+ it('falls back to README first non-header line', () => {
329
+ const repoDir = path.join(tmpDir, 'readme-repo');
330
+ fs.mkdirSync(path.join(repoDir, '.git'), { recursive: true });
331
+ fs.writeFileSync(path.join(repoDir, 'README.md'), '# Title\n\nThis is the description line.\n');
332
+ const result = discoverRepos(tmpDir);
333
+ assert.strictEqual(result[0].description, 'This is the description line.');
334
+ });
335
+
336
+ it('returns empty description when no package.json or README', () => {
337
+ fs.mkdirSync(path.join(tmpDir, 'bare-repo', '.git'), { recursive: true });
338
+ const result = discoverRepos(tmpDir);
339
+ assert.strictEqual(result[0].description, '');
340
+ });
341
+
342
+ it('returns repos with name, path, url, description', () => {
343
+ fs.mkdirSync(path.join(tmpDir, 'my-repo', '.git'), { recursive: true });
344
+ const result = discoverRepos(tmpDir);
345
+ assert.ok('name' in result[0]);
346
+ assert.ok('path' in result[0]);
347
+ assert.ok('url' in result[0]);
348
+ assert.ok('description' in result[0]);
349
+ assert.strictEqual(result[0].path, './my-repo');
350
+ });
351
+
352
+ it('returns empty array when directory has no git repos', () => {
353
+ fs.mkdirSync(path.join(tmpDir, 'not-a-repo'));
354
+ fs.mkdirSync(path.join(tmpDir, 'also-not'));
355
+ const result = discoverRepos(tmpDir);
356
+ assert.strictEqual(result.length, 0);
357
+ });
358
+ });
359
+
360
+ // ─── discoverSiblingRepos ─────────────────────────────────────────────────────
361
+
362
+ describe('discoverSiblingRepos', () => {
363
+ let tmpDir;
364
+ beforeEach(() => { tmpDir = createTempDir(); });
365
+ afterEach(() => { cleanupDir(tmpDir); });
366
+
367
+ it('finds sibling directories containing .git/', () => {
368
+ // tmpDir acts as parent; product-root and sibling-repo are siblings
369
+ fs.mkdirSync(path.join(tmpDir, 'product-root'), { recursive: true });
370
+ fs.mkdirSync(path.join(tmpDir, 'sibling-repo', '.git'), { recursive: true });
371
+ const result = discoverSiblingRepos(path.join(tmpDir, 'product-root'));
372
+ assert.strictEqual(result.length, 1);
373
+ assert.strictEqual(result[0].name, 'sibling-repo');
374
+ assert.strictEqual(result[0].path, '../sibling-repo');
375
+ });
376
+
377
+ it('excludes the product root directory itself', () => {
378
+ fs.mkdirSync(path.join(tmpDir, 'product-root', '.git'), { recursive: true });
379
+ fs.mkdirSync(path.join(tmpDir, 'sibling', '.git'), { recursive: true });
380
+ const result = discoverSiblingRepos(path.join(tmpDir, 'product-root'));
381
+ assert.strictEqual(result.length, 1);
382
+ assert.strictEqual(result[0].name, 'sibling');
383
+ // product-root must NOT appear
384
+ const names = result.map(r => r.name);
385
+ assert.ok(!names.includes('product-root'));
386
+ });
387
+
388
+ it('excludes dot-directories and node_modules', () => {
389
+ fs.mkdirSync(path.join(tmpDir, 'product-root'), { recursive: true });
390
+ fs.mkdirSync(path.join(tmpDir, '.hidden', '.git'), { recursive: true });
391
+ fs.mkdirSync(path.join(tmpDir, 'node_modules', '.git'), { recursive: true });
392
+ const result = discoverSiblingRepos(path.join(tmpDir, 'product-root'));
393
+ assert.strictEqual(result.length, 0);
394
+ });
395
+
396
+ it('excludes directories without .git/', () => {
397
+ fs.mkdirSync(path.join(tmpDir, 'product-root'), { recursive: true });
398
+ fs.mkdirSync(path.join(tmpDir, 'not-a-repo'), { recursive: true });
399
+ const result = discoverSiblingRepos(path.join(tmpDir, 'product-root'));
400
+ assert.strictEqual(result.length, 0);
401
+ });
402
+
403
+ it('uses ../ path prefix for sibling repos', () => {
404
+ fs.mkdirSync(path.join(tmpDir, 'product-root'), { recursive: true });
405
+ fs.mkdirSync(path.join(tmpDir, 'my-app', '.git'), { recursive: true });
406
+ const result = discoverSiblingRepos(path.join(tmpDir, 'product-root'));
407
+ assert.strictEqual(result[0].path, '../my-app');
408
+ });
409
+
410
+ it('extracts description from sibling repo package.json', () => {
411
+ fs.mkdirSync(path.join(tmpDir, 'product-root'), { recursive: true });
412
+ const pkgRepo = path.join(tmpDir, 'pkg-repo');
413
+ fs.mkdirSync(path.join(pkgRepo, '.git'), { recursive: true });
414
+ fs.writeFileSync(path.join(pkgRepo, 'package.json'), JSON.stringify({ description: 'Sibling package desc' }));
415
+ const result = discoverSiblingRepos(path.join(tmpDir, 'product-root'));
416
+ assert.strictEqual(result[0].description, 'Sibling package desc');
417
+ });
418
+
419
+ it('returns empty array when parent dir has no git repos besides self', () => {
420
+ fs.mkdirSync(path.join(tmpDir, 'product-root', '.git'), { recursive: true });
421
+ const result = discoverSiblingRepos(path.join(tmpDir, 'product-root'));
422
+ assert.strictEqual(result.length, 0);
423
+ });
424
+ });
425
+
426
+ // ─── syncGitignore ───────────────────────────────────────────────────────────
427
+
428
+ describe('syncGitignore', () => {
429
+ let tmpDir;
430
+ beforeEach(() => { tmpDir = createTempDir(); });
431
+ afterEach(() => { cleanupDir(tmpDir); });
432
+
433
+ it('creates .gitignore with DGS section when file does not exist', () => {
434
+ syncGitignore(tmpDir, ['./web-app', './server']);
435
+ assert.ok(fs.existsSync(path.join(tmpDir, '.gitignore')));
436
+ });
437
+
438
+ it('includes DGS marker comments', () => {
439
+ syncGitignore(tmpDir, ['./web-app']);
440
+ const content = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8');
441
+ assert.ok(content.includes('# DGS managed repos - do not edit below'));
442
+ assert.ok(content.includes('# end DGS managed repos'));
443
+ });
444
+
445
+ it('preserves existing non-DGS entries', () => {
446
+ fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'node_modules/\n.env\n');
447
+ syncGitignore(tmpDir, ['./web-app']);
448
+ const content = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8');
449
+ assert.ok(content.includes('node_modules/'));
450
+ assert.ok(content.includes('.env'));
451
+ });
452
+
453
+ it('replaces DGS section on subsequent calls', () => {
454
+ syncGitignore(tmpDir, ['./web-app']);
455
+ syncGitignore(tmpDir, ['./server']);
456
+ const content = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8');
457
+ assert.ok(!content.includes('web-app/'));
458
+ assert.ok(content.includes('server/'));
459
+ // Only one pair of markers
460
+ const starts = content.split('# DGS managed repos - do not edit below').length - 1;
461
+ assert.strictEqual(starts, 1);
462
+ });
463
+
464
+ it('includes repo paths with trailing /', () => {
465
+ syncGitignore(tmpDir, ['./web-app']);
466
+ const content = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8');
467
+ assert.ok(content.includes('web-app/'));
468
+ });
469
+
470
+ it('handles empty repoPaths array (no-op)', () => {
471
+ // Empty array after filtering means no .gitignore modification
472
+ fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'node_modules/\n');
473
+ syncGitignore(tmpDir, []);
474
+ const content = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8');
475
+ assert.strictEqual(content, 'node_modules/\n', '.gitignore should be unchanged');
476
+ assert.ok(!content.includes('# DGS managed repos'), 'No DGS markers for empty paths');
477
+ });
478
+
479
+ it('strips ./ prefix from paths', () => {
480
+ syncGitignore(tmpDir, ['./web-app']);
481
+ const content = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8');
482
+ assert.ok(!content.includes('./web-app'));
483
+ assert.ok(content.includes('web-app/'));
484
+ });
485
+
486
+ it('second call with different list replaces first', () => {
487
+ syncGitignore(tmpDir, ['./alpha', './beta']);
488
+ syncGitignore(tmpDir, ['./gamma']);
489
+ const content = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8');
490
+ assert.ok(!content.includes('alpha/'));
491
+ assert.ok(!content.includes('beta/'));
492
+ assert.ok(content.includes('gamma/'));
493
+ });
494
+
495
+ it('preserves content before AND after DGS section', () => {
496
+ // Create initial .gitignore with content before the section
497
+ fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'node_modules/\n.env\n');
498
+ syncGitignore(tmpDir, ['./web-app']);
499
+ // Now manually add content after the section
500
+ let content = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8');
501
+ content += '\n# Custom trailing section\n*.log\n';
502
+ fs.writeFileSync(path.join(tmpDir, '.gitignore'), content);
503
+ // Now sync again — should preserve both before and after
504
+ syncGitignore(tmpDir, ['./server']);
505
+ const final = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8');
506
+ assert.ok(final.includes('node_modules/'));
507
+ assert.ok(final.includes('.env'));
508
+ assert.ok(final.includes('*.log'));
509
+ assert.ok(final.includes('server/'));
510
+ assert.ok(!final.includes('web-app/'));
511
+ });
512
+ });
513
+
514
+ // ─── validateRepoPaths ───────────────────────────────────────────────────────
515
+
516
+ describe('validateRepoPaths', () => {
517
+ let tmpDir;
518
+ beforeEach(() => { tmpDir = createTempDir(); });
519
+ afterEach(() => { cleanupDir(tmpDir); });
520
+
521
+ it('returns valid and missing arrays', () => {
522
+ fs.mkdirSync(path.join(tmpDir, 'existing'));
523
+ const repos = [
524
+ { name: 'existing', path: './existing' },
525
+ { name: 'gone', path: './gone' },
526
+ ];
527
+ const result = validateRepoPaths(tmpDir, repos);
528
+ assert.ok(Array.isArray(result.valid));
529
+ assert.ok(Array.isArray(result.missing));
530
+ });
531
+
532
+ it('reports existing repos in valid', () => {
533
+ fs.mkdirSync(path.join(tmpDir, 'myrepo'));
534
+ const repos = [{ name: 'myrepo', path: './myrepo' }];
535
+ const result = validateRepoPaths(tmpDir, repos);
536
+ assert.strictEqual(result.valid.length, 1);
537
+ assert.strictEqual(result.valid[0].name, 'myrepo');
538
+ });
539
+
540
+ it('reports missing repos in missing', () => {
541
+ const repos = [{ name: 'gone', path: './gone' }];
542
+ const result = validateRepoPaths(tmpDir, repos);
543
+ assert.strictEqual(result.missing.length, 1);
544
+ assert.strictEqual(result.missing[0].name, 'gone');
545
+ });
546
+
547
+ it('handles empty repos array', () => {
548
+ const result = validateRepoPaths(tmpDir, []);
549
+ assert.strictEqual(result.valid.length, 0);
550
+ assert.strictEqual(result.missing.length, 0);
551
+ });
552
+ });
553
+
554
+ // ─── hasPathConflict ─────────────────────────────────────────────────────────
555
+
556
+ describe('hasPathConflict', () => {
557
+ it('detects new path as prefix of existing', () => {
558
+ const existing = [{ name: 'api', path: './services/api' }];
559
+ const result = hasPathConflict('./services', existing);
560
+ assert.strictEqual(result.conflict, true);
561
+ });
562
+
563
+ it('detects existing path as prefix of new', () => {
564
+ const existing = [{ name: 'services', path: './services' }];
565
+ const result = hasPathConflict('./services/api', existing);
566
+ assert.strictEqual(result.conflict, true);
567
+ });
568
+
569
+ it('no conflict for unrelated paths', () => {
570
+ const existing = [{ name: 'server', path: './server' }];
571
+ const result = hasPathConflict('./web-app', existing);
572
+ assert.strictEqual(result.conflict, false);
573
+ });
574
+
575
+ it('no conflict for paths sharing characters but not prefix boundary', () => {
576
+ const existing = [{ name: 'web', path: './web' }];
577
+ const result = hasPathConflict('./web-app', existing);
578
+ assert.strictEqual(result.conflict, false);
579
+ });
580
+
581
+ it('detects conflict with identical paths (same repo twice)', () => {
582
+ const existing = [{ name: 'api', path: './api' }];
583
+ const result = hasPathConflict('./api', existing);
584
+ assert.strictEqual(result.conflict, true);
585
+ });
586
+ });
587
+
588
+ // ─── writeProjectsMd ─────────────────────────────────────────────────────────
589
+
590
+ describe('writeProjectsMd', () => {
591
+ let tmpDir;
592
+ beforeEach(() => { tmpDir = createTempDir(); fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true }); });
593
+ afterEach(() => { cleanupDir(tmpDir); });
594
+
595
+ it('creates file with # Projects header', () => {
596
+ writeProjectsMd(tmpDir, []);
597
+ const content = fs.readFileSync(path.join(tmpDir, '.planning', 'PROJECTS.md'), 'utf-8');
598
+ assert.ok(content.startsWith('# Projects'));
599
+ });
600
+
601
+ it('includes Active and Completed sections', () => {
602
+ writeProjectsMd(tmpDir, []);
603
+ const content = fs.readFileSync(path.join(tmpDir, '.planning', 'PROJECTS.md'), 'utf-8');
604
+ assert.ok(content.includes('## Active'));
605
+ assert.ok(content.includes('## Completed'));
606
+ });
607
+
608
+ it('writes to .planning/PROJECTS.md', () => {
609
+ writeProjectsMd(tmpDir, []);
610
+ assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'PROJECTS.md')));
611
+ });
612
+ });
613
+
614
+ // ─── Roundtrip ───────────────────────────────────────────────────────────────
615
+
616
+ describe('writeReposMd -> parseReposMd roundtrip', () => {
617
+ let tmpDir;
618
+ beforeEach(() => { tmpDir = createTempDir(); fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true }); });
619
+ afterEach(() => { cleanupDir(tmpDir); });
620
+
621
+ it('write then parse produces identical data', () => {
622
+ const repos = [
623
+ { name: 'web', path: './web', url: 'https://github.com/org/web', description: 'Frontend' },
624
+ { name: 'api', path: './api', url: '', description: 'Backend API' },
625
+ ];
626
+ writeReposMd(tmpDir, repos);
627
+ const parsed = parseReposMd(tmpDir);
628
+ assert.ok(parsed);
629
+ assert.strictEqual(parsed.repos.length, 2);
630
+ assert.deepStrictEqual(parsed.repos[0], repos[0]);
631
+ assert.deepStrictEqual(parsed.repos[1], repos[1]);
632
+ });
633
+ });
634
+
635
+ // ─── autoPopulateReposTags ──────────────────────────────────────────────────
636
+
637
+ const { autoPopulateReposTags, validateReposConsistency } = require('./repos.cjs');
638
+
639
+ describe('autoPopulateReposTags', () => {
640
+ const repos = [
641
+ { name: 'my-app', path: './my-app' },
642
+ { name: 'api-service', path: './api-service' },
643
+ { name: 'services-api', path: './services/api' },
644
+ { name: 'services', path: './services' },
645
+ ];
646
+
647
+ it('single file, single repo — populates repos tag', () => {
648
+ const plan = `<task type="auto">
649
+ <name>Task 1</name>
650
+ <files>my-app/src/index.js</files>
651
+ <action>Do something</action>
652
+ <verify>Check it</verify>
653
+ <done>Done</done>
654
+ </task>`;
655
+ const result = autoPopulateReposTags(plan, repos);
656
+ assert.ok(result.content.includes('<repos>my-app</repos>'));
657
+ assert.strictEqual(result.warnings.length, 0);
658
+ });
659
+
660
+ it('multiple files, same repo — single repo in tag', () => {
661
+ const plan = `<task type="auto">
662
+ <name>Task 1</name>
663
+ <files>my-app/src/a.js, my-app/src/b.js</files>
664
+ <action>Do something</action>
665
+ <verify>Check it</verify>
666
+ <done>Done</done>
667
+ </task>`;
668
+ const result = autoPopulateReposTags(plan, repos);
669
+ assert.ok(result.content.includes('<repos>my-app</repos>'));
670
+ });
671
+
672
+ it('multiple files, different repos — sorted alphabetically', () => {
673
+ const plan = `<task type="auto">
674
+ <name>Task 1</name>
675
+ <files>my-app/src/a.js, api-service/src/b.js</files>
676
+ <action>Do something</action>
677
+ <verify>Check it</verify>
678
+ <done>Done</done>
679
+ </task>`;
680
+ const result = autoPopulateReposTags(plan, repos);
681
+ assert.ok(result.content.includes('<repos>api-service, my-app</repos>'));
682
+ });
683
+
684
+ it('file not matching any repo — warning about repo-relative paths, no repo added', () => {
685
+ const plan = `<task type="auto">
686
+ <name>Task 1</name>
687
+ <files>unknown/file.js</files>
688
+ <action>Do something</action>
689
+ <verify>Check it</verify>
690
+ <done>Done</done>
691
+ </task>`;
692
+ const result = autoPopulateReposTags(plan, repos);
693
+ assert.ok(result.warnings.length > 0);
694
+ // With repo-relative path support, unresolvable files trigger repo-relative warning
695
+ assert.ok(result.warnings[0].includes('repo-relative'));
696
+ // Content should be unchanged (no repos tag added)
697
+ assert.ok(!result.content.includes('<repos>'));
698
+ });
699
+
700
+ it('existing repos tag gets replaced with auto-populated values', () => {
701
+ const plan = `<task type="auto">
702
+ <name>Task 1</name>
703
+ <files>my-app/src/index.js</files>
704
+ <repos>old-repo</repos>
705
+ <action>Do something</action>
706
+ <verify>Check it</verify>
707
+ <done>Done</done>
708
+ </task>`;
709
+ const result = autoPopulateReposTags(plan, repos);
710
+ assert.ok(result.content.includes('<repos>my-app</repos>'));
711
+ assert.ok(!result.content.includes('old-repo'));
712
+ });
713
+
714
+ it('empty files block — empty repos tag, no error', () => {
715
+ const plan = `<task type="auto">
716
+ <name>Task 1</name>
717
+ <files></files>
718
+ <action>Do something</action>
719
+ <verify>Check it</verify>
720
+ <done>Done</done>
721
+ </task>`;
722
+ const result = autoPopulateReposTags(plan, repos);
723
+ assert.ok(result.content.includes('<repos></repos>'));
724
+ assert.strictEqual(result.warnings.length, 0);
725
+ });
726
+
727
+ it('multiple tasks each get their own repos tag', () => {
728
+ const plan = `<task type="auto">
729
+ <name>Task 1</name>
730
+ <files>my-app/src/index.js</files>
731
+ <action>Do something</action>
732
+ <verify>Check it</verify>
733
+ <done>Done</done>
734
+ </task>
735
+ <task type="auto">
736
+ <name>Task 2</name>
737
+ <files>api-service/src/server.js</files>
738
+ <action>Do something else</action>
739
+ <verify>Check it</verify>
740
+ <done>Done</done>
741
+ </task>`;
742
+ const result = autoPopulateReposTags(plan, repos);
743
+ assert.ok(result.content.includes('<repos>my-app</repos>'));
744
+ assert.ok(result.content.includes('<repos>api-service</repos>'));
745
+ });
746
+
747
+ it('repos not in initial_repos are accepted (auto-expansion)', () => {
748
+ const limitedRepos = [
749
+ { name: 'my-app', path: './my-app' },
750
+ { name: 'extra-repo', path: './extra-repo' },
751
+ ];
752
+ const plan = `<task type="auto">
753
+ <name>Task 1</name>
754
+ <files>extra-repo/src/index.js</files>
755
+ <action>Do something</action>
756
+ <verify>Check it</verify>
757
+ <done>Done</done>
758
+ </task>`;
759
+ const result = autoPopulateReposTags(plan, limitedRepos);
760
+ assert.ok(result.content.includes('<repos>extra-repo</repos>'));
761
+ assert.strictEqual(result.warnings.length, 0);
762
+ });
763
+
764
+ it('file paths with ./ prefix are normalized', () => {
765
+ const plan = `<task type="auto">
766
+ <name>Task 1</name>
767
+ <files>./my-app/src/index.js</files>
768
+ <action>Do something</action>
769
+ <verify>Check it</verify>
770
+ <done>Done</done>
771
+ </task>`;
772
+ const result = autoPopulateReposTags(plan, repos);
773
+ assert.ok(result.content.includes('<repos>my-app</repos>'));
774
+ });
775
+
776
+ it('longest-prefix-match: services/api/src/index.js matches services/api not services', () => {
777
+ const plan = `<task type="auto">
778
+ <name>Task 1</name>
779
+ <files>services/api/src/index.js</files>
780
+ <action>Do something</action>
781
+ <verify>Check it</verify>
782
+ <done>Done</done>
783
+ </task>`;
784
+ const result = autoPopulateReposTags(plan, repos);
785
+ assert.ok(result.content.includes('<repos>services-api</repos>'));
786
+ });
787
+
788
+ it('files on separate lines are handled', () => {
789
+ const plan = `<task type="auto">
790
+ <name>Task 1</name>
791
+ <files>
792
+ my-app/src/a.js
793
+ api-service/src/b.js
794
+ </files>
795
+ <action>Do something</action>
796
+ <verify>Check it</verify>
797
+ <done>Done</done>
798
+ </task>`;
799
+ const result = autoPopulateReposTags(plan, repos);
800
+ assert.ok(result.content.includes('<repos>api-service, my-app</repos>'));
801
+ });
802
+
803
+ it('mixed known and unknown files — repos populated and warnings for unknowns', () => {
804
+ const plan = `<task type="auto">
805
+ <name>Task 1</name>
806
+ <files>my-app/src/index.js, unknown/file.js</files>
807
+ <action>Do something</action>
808
+ <verify>Check it</verify>
809
+ <done>Done</done>
810
+ </task>`;
811
+ const result = autoPopulateReposTags(plan, repos);
812
+ assert.ok(result.content.includes('<repos>my-app</repos>'));
813
+ assert.ok(result.warnings.length > 0);
814
+ });
815
+ });
816
+
817
+ // ─── validateReposConsistency ───────────────────────────────────────────────
818
+
819
+ describe('validateReposConsistency', () => {
820
+ const repos = [
821
+ { name: 'my-app', path: './my-app' },
822
+ { name: 'api-service', path: './api-service' },
823
+ { name: 'shared-lib', path: './shared-lib' },
824
+ ];
825
+
826
+ it('consistent plan passes validation', () => {
827
+ const plan = `<task type="auto">
828
+ <name>Task 1</name>
829
+ <files>my-app/src/index.js</files>
830
+ <repos>my-app</repos>
831
+ <action>Do something</action>
832
+ <verify>Check it</verify>
833
+ <done>Done</done>
834
+ </task>`;
835
+ const result = validateReposConsistency(plan, repos);
836
+ assert.strictEqual(result.valid, true);
837
+ assert.strictEqual(result.mismatches.length, 0);
838
+ });
839
+
840
+ it('extra repo in repos tag — mismatch with extra', () => {
841
+ const plan = `<task type="auto">
842
+ <name>Task 1</name>
843
+ <files>my-app/src/index.js</files>
844
+ <repos>my-app, shared-lib</repos>
845
+ <action>Do something</action>
846
+ <verify>Check it</verify>
847
+ <done>Done</done>
848
+ </task>`;
849
+ const result = validateReposConsistency(plan, repos);
850
+ assert.strictEqual(result.valid, false);
851
+ assert.strictEqual(result.mismatches.length, 1);
852
+ assert.ok(result.mismatches[0].extra.includes('shared-lib'));
853
+ });
854
+
855
+ it('missing repo in repos tag — mismatch with missing', () => {
856
+ const plan = `<task type="auto">
857
+ <name>Task 1</name>
858
+ <files>my-app/src/index.js, api-service/src/server.js</files>
859
+ <repos>my-app</repos>
860
+ <action>Do something</action>
861
+ <verify>Check it</verify>
862
+ <done>Done</done>
863
+ </task>`;
864
+ const result = validateReposConsistency(plan, repos);
865
+ assert.strictEqual(result.valid, false);
866
+ assert.strictEqual(result.mismatches.length, 1);
867
+ assert.ok(result.mismatches[0].missing.includes('api-service'));
868
+ });
869
+
870
+ it('empty repos and files — valid', () => {
871
+ const plan = `<task type="auto">
872
+ <name>Task 1</name>
873
+ <files></files>
874
+ <repos></repos>
875
+ <action>Do something</action>
876
+ <verify>Check it</verify>
877
+ <done>Done</done>
878
+ </task>`;
879
+ const result = validateReposConsistency(plan, repos);
880
+ assert.strictEqual(result.valid, true);
881
+ assert.strictEqual(result.mismatches.length, 0);
882
+ });
883
+
884
+ it('multiple mismatches across multiple tasks', () => {
885
+ const plan = `<task type="auto">
886
+ <name>Task 1</name>
887
+ <files>my-app/src/index.js</files>
888
+ <repos>shared-lib</repos>
889
+ <action>Do something</action>
890
+ <verify>Check it</verify>
891
+ <done>Done</done>
892
+ </task>
893
+ <task type="auto">
894
+ <name>Task 2</name>
895
+ <files>api-service/src/server.js</files>
896
+ <repos>my-app</repos>
897
+ <action>Do something</action>
898
+ <verify>Check it</verify>
899
+ <done>Done</done>
900
+ </task>`;
901
+ const result = validateReposConsistency(plan, repos);
902
+ assert.strictEqual(result.valid, false);
903
+ assert.strictEqual(result.mismatches.length, 2);
904
+ });
905
+
906
+ it('mismatch includes task name, repos_listed, repos_derived, extra, missing', () => {
907
+ const plan = `<task type="auto">
908
+ <name>Task 1</name>
909
+ <files>my-app/src/index.js</files>
910
+ <repos>my-app, shared-lib</repos>
911
+ <action>Do something</action>
912
+ <verify>Check it</verify>
913
+ <done>Done</done>
914
+ </task>`;
915
+ const result = validateReposConsistency(plan, repos);
916
+ const mm = result.mismatches[0];
917
+ assert.ok('task' in mm);
918
+ assert.ok('repos_listed' in mm);
919
+ assert.ok('repos_derived' in mm);
920
+ assert.ok('extra' in mm);
921
+ assert.ok('missing' in mm);
922
+ });
923
+
924
+ it('task without repos tag treated as empty listed repos', () => {
925
+ const plan = `<task type="auto">
926
+ <name>Task 1</name>
927
+ <files>my-app/src/index.js</files>
928
+ <action>Do something</action>
929
+ <verify>Check it</verify>
930
+ <done>Done</done>
931
+ </task>`;
932
+ const result = validateReposConsistency(plan, repos);
933
+ assert.strictEqual(result.valid, false);
934
+ assert.ok(result.mismatches[0].missing.includes('my-app'));
935
+ });
936
+ });
937
+
938
+ // ─── resolveFileToRepo edge cases (Pitfall 2) ──────────────────────────────
939
+
940
+ describe('resolveFileToRepo edge cases (Pitfall 2)', () => {
941
+ const repos = [
942
+ { name: 'web', path: './web' },
943
+ { name: 'web-app', path: './web-app' },
944
+ { name: 'services-api', path: './services/api' },
945
+ { name: 'services-api-gateway', path: './services/api-gateway' },
946
+ { name: 'server', path: './server' },
947
+ ];
948
+
949
+ // 1. Bare repo path (no trailing slash, no file after it)
950
+ it('bare repo path "web-app" returns null (ambiguous)', () => {
951
+ const result = resolveFileToRepo('web-app', repos);
952
+ assert.strictEqual(result, null);
953
+ });
954
+
955
+ it('bare repo path "server" returns null (ambiguous)', () => {
956
+ const result = resolveFileToRepo('server', repos);
957
+ assert.strictEqual(result, null);
958
+ });
959
+
960
+ // 2. File path with trailing slash
961
+ it('repo path with trailing slash "web-app/" returns null (directory, not file)', () => {
962
+ const result = resolveFileToRepo('web-app/', repos);
963
+ assert.strictEqual(result, null);
964
+ });
965
+
966
+ it('subpath with trailing slash "web-app/src/" matches web-app (trailing slash normalized)', () => {
967
+ const result = resolveFileToRepo('web-app/src/', repos);
968
+ assert.ok(result);
969
+ assert.strictEqual(result.name, 'web-app');
970
+ });
971
+
972
+ // 3. Multiple ./ variations — explicit equality assertion
973
+ it('./web-app/file.ts and web-app/file.ts return identical results', () => {
974
+ const withDot = resolveFileToRepo('./web-app/file.ts', repos);
975
+ const without = resolveFileToRepo('web-app/file.ts', repos);
976
+ assert.deepStrictEqual(withDot, without);
977
+ });
978
+
979
+ // 4. Nested repos with confusing prefixes (explicit regression)
980
+ it('services/api-gateway/handler.ts must be services-api-gateway', () => {
981
+ const result = resolveFileToRepo('services/api-gateway/handler.ts', repos);
982
+ assert.ok(result);
983
+ assert.strictEqual(result.name, 'services-api-gateway');
984
+ });
985
+
986
+ it('services/api/handler.ts must be services-api', () => {
987
+ const result = resolveFileToRepo('services/api/handler.ts', repos);
988
+ assert.ok(result);
989
+ assert.strictEqual(result.name, 'services-api');
990
+ });
991
+
992
+ it('services/apifoo/handler.ts must be null (no matching repo)', () => {
993
+ const result = resolveFileToRepo('services/apifoo/handler.ts', repos);
994
+ assert.strictEqual(result, null);
995
+ });
996
+
997
+ // 5. Path with redundant separators
998
+ it('double slashes "web-app//src/file.ts" matches web-app (normalized)', () => {
999
+ const result = resolveFileToRepo('web-app//src/file.ts', repos);
1000
+ assert.ok(result);
1001
+ assert.strictEqual(result.name, 'web-app');
1002
+ });
1003
+ });
1004
+
1005
+ // ─── Edge Cases: autoPopulateReposTags ──────────────────────────────────────
1006
+
1007
+ describe('autoPopulateReposTags edge cases', () => {
1008
+ const repos = [
1009
+ { name: 'my-app', path: './my-app' },
1010
+ { name: 'api-service', path: './api-service' },
1011
+ ];
1012
+
1013
+ it('plan with no task blocks returns unchanged content', () => {
1014
+ const plan = `---
1015
+ phase: 04
1016
+ plan: 01
1017
+ ---
1018
+ <objective>Some plan</objective>
1019
+ <verification>Check stuff</verification>`;
1020
+ const result = autoPopulateReposTags(plan, repos);
1021
+ assert.strictEqual(result.content, plan);
1022
+ assert.strictEqual(result.warnings.length, 0);
1023
+ });
1024
+
1025
+ it('file path with trailing whitespace/newlines is trimmed', () => {
1026
+ const plan = `<task type="auto">
1027
+ <name>Task 1</name>
1028
+ <files> my-app/src/index.js </files>
1029
+ <action>Do something</action>
1030
+ <verify>Check it</verify>
1031
+ <done>Done</done>
1032
+ </task>`;
1033
+ const result = autoPopulateReposTags(plan, repos);
1034
+ assert.ok(result.content.includes('<repos>my-app</repos>'));
1035
+ assert.strictEqual(result.warnings.length, 0);
1036
+ });
1037
+
1038
+ it('repo path with ./ prefix in repos array is normalized correctly', () => {
1039
+ const reposWithDotSlash = [
1040
+ { name: 'my-app', path: './my-app' },
1041
+ ];
1042
+ const plan = `<task type="auto">
1043
+ <name>Task 1</name>
1044
+ <files>my-app/src/index.js</files>
1045
+ <action>Do something</action>
1046
+ <verify>Check it</verify>
1047
+ <done>Done</done>
1048
+ </task>`;
1049
+ const result = autoPopulateReposTags(plan, reposWithDotSlash);
1050
+ assert.ok(result.content.includes('<repos>my-app</repos>'));
1051
+ });
1052
+
1053
+ it('repos tag already correct matches derived — no change needed', () => {
1054
+ const plan = `<task type="auto">
1055
+ <name>Task 1</name>
1056
+ <files>my-app/src/index.js</files>
1057
+ <repos>my-app</repos>
1058
+ <action>Do something</action>
1059
+ <verify>Check it</verify>
1060
+ <done>Done</done>
1061
+ </task>`;
1062
+ const result = autoPopulateReposTags(plan, repos);
1063
+ assert.ok(result.content.includes('<repos>my-app</repos>'));
1064
+ assert.strictEqual(result.warnings.length, 0);
1065
+ });
1066
+
1067
+ it('roundtrip: autoPopulate then validate produces valid result', () => {
1068
+ const plan = `<task type="auto">
1069
+ <name>Task 1</name>
1070
+ <files>my-app/src/a.js, api-service/src/b.js</files>
1071
+ <action>Do something</action>
1072
+ <verify>Check it</verify>
1073
+ <done>Done</done>
1074
+ </task>`;
1075
+ const populated = autoPopulateReposTags(plan, repos);
1076
+ const validation = validateReposConsistency(populated.content, repos);
1077
+ assert.strictEqual(validation.valid, true);
1078
+ assert.strictEqual(validation.mismatches.length, 0);
1079
+ });
1080
+
1081
+ it('task with files tag but no closing files tag is handled gracefully', () => {
1082
+ const plan = `<task type="auto">
1083
+ <name>Task 1</name>
1084
+ <files>my-app/src/index.js
1085
+ <action>Do something</action>
1086
+ <verify>Check it</verify>
1087
+ <done>Done</done>
1088
+ </task>`;
1089
+ // No <files> closing tag means regex won't match — task returned unchanged
1090
+ const result = autoPopulateReposTags(plan, repos);
1091
+ assert.ok(!result.content.includes('<repos>'));
1092
+ assert.strictEqual(result.warnings.length, 0);
1093
+ });
1094
+ });
1095
+
1096
+ // ─── autoPopulateReposTags with repo-relative paths ─────────────────────────
1097
+
1098
+ describe('autoPopulateReposTags with repo-relative paths', () => {
1099
+ const repos = [
1100
+ { name: 'my-app', path: './my-app' },
1101
+ { name: 'api-service', path: './api-service' },
1102
+ ];
1103
+
1104
+ it('repo-relative files with existing <repos> tag — returns content unchanged, no warnings', () => {
1105
+ const plan = `<task type="auto">
1106
+ <name>Task 1</name>
1107
+ <files>src/index.ts, src/app.ts</files>
1108
+ <repos>my-app</repos>
1109
+ <action>Do something</action>
1110
+ <verify>Check it</verify>
1111
+ <done>Done</done>
1112
+ </task>`;
1113
+ const result = autoPopulateReposTags(plan, repos);
1114
+ // Content should be unchanged — existing repos tag preserved
1115
+ assert.strictEqual(result.content, plan);
1116
+ assert.strictEqual(result.warnings.length, 0);
1117
+ });
1118
+
1119
+ it('repo-relative files with NO <repos> tag — returns warning about manual tag needed', () => {
1120
+ const plan = `<task type="auto">
1121
+ <name>Task 1</name>
1122
+ <files>src/index.ts, src/app.ts</files>
1123
+ <action>Do something</action>
1124
+ <verify>Check it</verify>
1125
+ <done>Done</done>
1126
+ </task>`;
1127
+ const result = autoPopulateReposTags(plan, repos);
1128
+ // Content should be unchanged (no repos could be derived)
1129
+ assert.strictEqual(result.content, plan);
1130
+ // Should have a warning about adding repos tag manually
1131
+ assert.ok(result.warnings.length > 0);
1132
+ assert.ok(result.warnings[0].includes('repo-relative'));
1133
+ });
1134
+
1135
+ it('backwards compat: product-root-relative paths still auto-populate correctly', () => {
1136
+ const plan = `<task type="auto">
1137
+ <name>Task 1</name>
1138
+ <files>my-app/src/index.js, api-service/src/server.js</files>
1139
+ <action>Do something</action>
1140
+ <verify>Check it</verify>
1141
+ <done>Done</done>
1142
+ </task>`;
1143
+ const result = autoPopulateReposTags(plan, repos);
1144
+ assert.ok(result.content.includes('<repos>api-service, my-app</repos>'));
1145
+ assert.strictEqual(result.warnings.length, 0);
1146
+ });
1147
+
1148
+ it('mixed: some product-root-relative, some repo-relative — handles gracefully', () => {
1149
+ const plan = `<task type="auto">
1150
+ <name>Task 1</name>
1151
+ <files>my-app/src/index.js, src/utils.ts</files>
1152
+ <action>Do something</action>
1153
+ <verify>Check it</verify>
1154
+ <done>Done</done>
1155
+ </task>`;
1156
+ const result = autoPopulateReposTags(plan, repos);
1157
+ // my-app/src/index.js resolves to my-app, src/utils.ts does not resolve
1158
+ // Since at least one file resolved, it populates repos from the resolved ones
1159
+ assert.ok(result.content.includes('<repos>my-app</repos>'));
1160
+ });
1161
+ });
1162
+
1163
+ // ─── validateReposConsistency with repo-relative paths ───────────────────────
1164
+
1165
+ describe('validateReposConsistency with repo-relative paths', () => {
1166
+ const repos = [
1167
+ { name: 'my-app', path: './my-app' },
1168
+ { name: 'api-service', path: './api-service' },
1169
+ ];
1170
+
1171
+ it('repo-relative files with matching <repos> tag — valid: true', () => {
1172
+ const plan = `<task type="auto">
1173
+ <name>Task 1</name>
1174
+ <files>src/index.ts, src/app.ts</files>
1175
+ <repos>my-app</repos>
1176
+ <action>Do something</action>
1177
+ <verify>Check it</verify>
1178
+ <done>Done</done>
1179
+ </task>`;
1180
+ const result = validateReposConsistency(plan, repos);
1181
+ assert.strictEqual(result.valid, true);
1182
+ assert.strictEqual(result.mismatches.length, 0);
1183
+ });
1184
+
1185
+ it('repo-relative files and no <repos> tag — valid: true (no repos on either side)', () => {
1186
+ const plan = `<task type="auto">
1187
+ <name>Task 1</name>
1188
+ <files>src/index.ts</files>
1189
+ <action>Do something</action>
1190
+ <verify>Check it</verify>
1191
+ <done>Done</done>
1192
+ </task>`;
1193
+ const result = validateReposConsistency(plan, repos);
1194
+ // Neither derived nor listed repos — no mismatch
1195
+ assert.strictEqual(result.valid, true);
1196
+ assert.strictEqual(result.mismatches.length, 0);
1197
+ });
1198
+
1199
+ it('backwards compat: product-root-relative files — works as before', () => {
1200
+ const plan = `<task type="auto">
1201
+ <name>Task 1</name>
1202
+ <files>my-app/src/index.js</files>
1203
+ <repos>my-app</repos>
1204
+ <action>Do something</action>
1205
+ <verify>Check it</verify>
1206
+ <done>Done</done>
1207
+ </task>`;
1208
+ const result = validateReposConsistency(plan, repos);
1209
+ assert.strictEqual(result.valid, true);
1210
+ assert.strictEqual(result.mismatches.length, 0);
1211
+ });
1212
+
1213
+ it('backwards compat: product-root-relative files with wrong repos tag — still detects mismatch', () => {
1214
+ const plan = `<task type="auto">
1215
+ <name>Task 1</name>
1216
+ <files>my-app/src/index.js</files>
1217
+ <repos>api-service</repos>
1218
+ <action>Do something</action>
1219
+ <verify>Check it</verify>
1220
+ <done>Done</done>
1221
+ </task>`;
1222
+ const result = validateReposConsistency(plan, repos);
1223
+ assert.strictEqual(result.valid, false);
1224
+ assert.strictEqual(result.mismatches.length, 1);
1225
+ });
1226
+ });
1227
+
1228
+ // ─── resolveFileToRepo with ../repo paths ───────────────────────────────────
1229
+
1230
+ describe('resolveFileToRepo with ../repo paths', () => {
1231
+ const repos = [
1232
+ { name: 'web-app', path: '../web-app' },
1233
+ { name: 'web', path: '../web' },
1234
+ { name: 'api-service', path: '../api-service' },
1235
+ { name: 'server', path: '../server' },
1236
+ ];
1237
+
1238
+ it('matches ../web-app/src/App.tsx to web-app', () => {
1239
+ const result = resolveFileToRepo('../web-app/src/App.tsx', repos);
1240
+ assert.ok(result);
1241
+ assert.strictEqual(result.name, 'web-app');
1242
+ assert.strictEqual(result.path, '../web-app');
1243
+ });
1244
+
1245
+ it('longest prefix: ../web-app matches over ../web', () => {
1246
+ const result = resolveFileToRepo('../web-app/src/App.tsx', repos);
1247
+ assert.ok(result);
1248
+ assert.strictEqual(result.name, 'web-app');
1249
+ });
1250
+
1251
+ it('bare path web-app/src/App.tsx also matches ../web-app repo', () => {
1252
+ const result = resolveFileToRepo('web-app/src/App.tsx', repos);
1253
+ assert.ok(result);
1254
+ assert.strictEqual(result.name, 'web-app');
1255
+ });
1256
+
1257
+ it('bare repo path ../web-app returns null (no file after it)', () => {
1258
+ const result = resolveFileToRepo('../web-app', repos);
1259
+ assert.strictEqual(result, null);
1260
+ });
1261
+
1262
+ it('trailing slash ../web-app/ returns null (directory not file)', () => {
1263
+ const result = resolveFileToRepo('../web-app/', repos);
1264
+ assert.strictEqual(result, null);
1265
+ });
1266
+
1267
+ it('double slash ../web-app//src/file.ts normalized and matches', () => {
1268
+ const result = resolveFileToRepo('../web-app//src/file.ts', repos);
1269
+ assert.ok(result);
1270
+ assert.strictEqual(result.name, 'web-app');
1271
+ });
1272
+
1273
+ it('absolute path resolves to correct repo when cwd is provided', () => {
1274
+ const cwd = '/Users/foo/product-root';
1275
+ const result = resolveFileToRepo('/Users/foo/api-service/src/bar.ts', repos, cwd);
1276
+ assert.ok(result);
1277
+ assert.strictEqual(result.name, 'api-service');
1278
+ });
1279
+
1280
+ it('../web/index.html matches web, not web-app', () => {
1281
+ const result = resolveFileToRepo('../web/index.html', repos);
1282
+ assert.ok(result);
1283
+ assert.strictEqual(result.name, 'web');
1284
+ });
1285
+
1286
+ it('returns null for unmatched ../unknown/file.ts', () => {
1287
+ const result = resolveFileToRepo('../unknown/file.ts', repos);
1288
+ assert.strictEqual(result, null);
1289
+ });
1290
+
1291
+ it('handles deeply nested paths', () => {
1292
+ const result = resolveFileToRepo('../api-service/src/controllers/users/index.ts', repos);
1293
+ assert.ok(result);
1294
+ assert.strictEqual(result.name, 'api-service');
1295
+ });
1296
+
1297
+ it('../server/api.ts returns name and path', () => {
1298
+ const result = resolveFileToRepo('../server/api.ts', repos);
1299
+ assert.ok(result);
1300
+ assert.strictEqual(result.name, 'server');
1301
+ assert.strictEqual(result.path, '../server');
1302
+ });
1303
+ });
1304
+
1305
+ // ─── hasPathConflict with ../repo paths ─────────────────────────────────────
1306
+
1307
+ describe('hasPathConflict with ../repo paths', () => {
1308
+ it('../app and ../app-admin do NOT conflict (different directory boundary)', () => {
1309
+ const existing = [{ name: 'app-admin', path: '../app-admin' }];
1310
+ const result = hasPathConflict('../app', existing);
1311
+ assert.strictEqual(result.conflict, false);
1312
+ });
1313
+
1314
+ it('../app and ../app conflict (identical path)', () => {
1315
+ const existing = [{ name: 'app', path: '../app' }];
1316
+ const result = hasPathConflict('../app', existing);
1317
+ assert.strictEqual(result.conflict, true);
1318
+ });
1319
+
1320
+ it('../services/api conflicts with ../services (prefix conflict)', () => {
1321
+ const existing = [{ name: 'services', path: '../services' }];
1322
+ const result = hasPathConflict('../services/api', existing);
1323
+ assert.strictEqual(result.conflict, true);
1324
+ });
1325
+
1326
+ it('../services conflicts with ../services/api (reverse prefix conflict)', () => {
1327
+ const existing = [{ name: 'services-api', path: '../services/api' }];
1328
+ const result = hasPathConflict('../services', existing);
1329
+ assert.strictEqual(result.conflict, true);
1330
+ });
1331
+
1332
+ it('../web and ../web-app do NOT conflict', () => {
1333
+ const existing = [{ name: 'web-app', path: '../web-app' }];
1334
+ const result = hasPathConflict('../web', existing);
1335
+ assert.strictEqual(result.conflict, false);
1336
+ });
1337
+ });
1338
+
1339
+ // ─── Path normalization for ../repo paths ───────────────────────────────────
1340
+
1341
+ describe('Path normalization for ../repo paths', () => {
1342
+ const repos = [
1343
+ { name: 'repo', path: '../repo' },
1344
+ ];
1345
+
1346
+ it('trailing slash stripped: ../repo/src/file.ts/ normalizes and matches', () => {
1347
+ const result = resolveFileToRepo('../repo/src/file.ts/', repos);
1348
+ assert.ok(result);
1349
+ assert.strictEqual(result.name, 'repo');
1350
+ });
1351
+
1352
+ it('double separator collapsed: ../repo//src/file.ts matches', () => {
1353
+ const result = resolveFileToRepo('../repo//src/file.ts', repos);
1354
+ assert.ok(result);
1355
+ assert.strictEqual(result.name, 'repo');
1356
+ });
1357
+
1358
+ it('./repo/src/file.ts and ../repo/src/file.ts both normalize to match', () => {
1359
+ const dotSlashRepos = [{ name: 'repo', path: './repo' }];
1360
+ const dotDotRepos = [{ name: 'repo', path: '../repo' }];
1361
+ const r1 = resolveFileToRepo('./repo/src/file.ts', dotSlashRepos);
1362
+ const r2 = resolveFileToRepo('../repo/src/file.ts', dotDotRepos);
1363
+ assert.ok(r1);
1364
+ assert.ok(r2);
1365
+ assert.strictEqual(r1.name, r2.name);
1366
+ });
1367
+
1368
+ it('bare path repo/src/file.ts matches when repo is ../repo', () => {
1369
+ const result = resolveFileToRepo('repo/src/file.ts', repos);
1370
+ assert.ok(result);
1371
+ assert.strictEqual(result.name, 'repo');
1372
+ });
1373
+ });
1374
+
1375
+ // ─── cmdReposAdd ../name format enforcement ──────────────────────────────────
1376
+
1377
+ describe('cmdReposAdd ../name format enforcement', () => {
1378
+ let tmpDir;
1379
+ const { execSync } = require('child_process');
1380
+
1381
+ beforeEach(() => {
1382
+ tmpDir = createTempDir();
1383
+ // Create a flat layout: product-root with .planning, and sibling repos
1384
+ const productRoot = path.join(tmpDir, 'product-root');
1385
+ fs.mkdirSync(path.join(productRoot, '.planning'), { recursive: true });
1386
+ // Write initial REPOS.md
1387
+ const reposMd = `# Repos
1388
+
1389
+ Registered repositories for this product. Managed by DGS — manual edits may be overwritten.
1390
+
1391
+ | Name | Path | GitHub URL | Description |
1392
+ |------|------|------------|-------------|
1393
+ `;
1394
+ fs.writeFileSync(path.join(productRoot, '.planning', 'REPOS.md'), reposMd);
1395
+ // Create sibling repo with .git
1396
+ const siblingRepo = path.join(tmpDir, 'my-repo');
1397
+ fs.mkdirSync(path.join(siblingRepo, '.git'), { recursive: true });
1398
+ // Create sibling without .git
1399
+ const noGitRepo = path.join(tmpDir, 'no-git-repo');
1400
+ fs.mkdirSync(noGitRepo, { recursive: true });
1401
+ });
1402
+ afterEach(() => { cleanupDir(tmpDir); });
1403
+
1404
+ // Helper to test cmdReposAdd via subprocess (it calls error() which does process.exit)
1405
+ function runCmdReposAdd(cwd, repoPath, opts) {
1406
+ const reposPath = require.resolve('./repos.cjs');
1407
+ const script = `
1408
+ const repos = require('${reposPath.replace(/\\/g, '\\\\')}');
1409
+ let exitMsg = null;
1410
+ let exitCode = null;
1411
+ const origExit = process.exit;
1412
+ process.exit = (code) => { exitCode = code; throw new Error('EXIT:' + code + ':' + exitMsg); };
1413
+ const origStderr = process.stderr.write;
1414
+ process.stderr.write = (data) => { exitMsg = data; };
1415
+ const origStdout = process.stdout.write;
1416
+ let stdoutData = '';
1417
+ process.stdout.write = (data) => { stdoutData += data; };
1418
+ try {
1419
+ repos.cmdReposAdd('${cwd.replace(/\\/g, '\\\\')}', '${repoPath}', ${JSON.stringify(opts || {})}, true);
1420
+ process.stdout.write = origStdout;
1421
+ console.log(JSON.stringify({success: true, output: stdoutData}));
1422
+ } catch (e) {
1423
+ process.stderr.write = origStderr;
1424
+ process.stdout.write = origStdout;
1425
+ process.exit = origExit;
1426
+ if (exitCode === 0) {
1427
+ console.log(JSON.stringify({success: true, output: stdoutData}));
1428
+ } else {
1429
+ console.log(JSON.stringify({success: false, error: e.message}));
1430
+ }
1431
+ }
1432
+ `;
1433
+ const result = execSync(`node -e '${script.replace(/'/g, "'\\''")}'`, { encoding: 'utf-8', timeout: 5000 }).trim();
1434
+ return JSON.parse(result);
1435
+ }
1436
+
1437
+ it('rejects ./repo path', () => {
1438
+ const productRoot = path.join(tmpDir, 'product-root');
1439
+ const result = runCmdReposAdd(productRoot, './my-repo', {});
1440
+ assert.strictEqual(result.success, false);
1441
+ assert.ok(result.error.includes('Repo paths must use ../name format'));
1442
+ });
1443
+
1444
+ it('rejects ../../deep/repo path', () => {
1445
+ const productRoot = path.join(tmpDir, 'product-root');
1446
+ const result = runCmdReposAdd(productRoot, '../../deep/repo', {});
1447
+ assert.strictEqual(result.success, false);
1448
+ assert.ok(result.error.includes('Repo paths must use ../name format'));
1449
+ });
1450
+
1451
+ it('rejects /absolute/path', () => {
1452
+ const productRoot = path.join(tmpDir, 'product-root');
1453
+ const result = runCmdReposAdd(productRoot, '/absolute/path', {});
1454
+ assert.strictEqual(result.success, false);
1455
+ assert.ok(result.error.includes('Repo paths must use ../name format'));
1456
+ });
1457
+
1458
+ it('rejects bare repo path (no ../ prefix)', () => {
1459
+ const productRoot = path.join(tmpDir, 'product-root');
1460
+ const result = runCmdReposAdd(productRoot, 'repo', {});
1461
+ assert.strictEqual(result.success, false);
1462
+ assert.ok(result.error.includes('Repo paths must use ../name format'));
1463
+ });
1464
+
1465
+ it('validates .git exists in target directory', () => {
1466
+ const productRoot = path.join(tmpDir, 'product-root');
1467
+ const result = runCmdReposAdd(productRoot, '../no-git-repo', {});
1468
+ assert.strictEqual(result.success, false);
1469
+ assert.ok(result.error.includes('not a git repository'));
1470
+ });
1471
+
1472
+ it('error message includes fix suggestion when repo not found', () => {
1473
+ const productRoot = path.join(tmpDir, 'product-root');
1474
+ const result = runCmdReposAdd(productRoot, '../nonexistent', {});
1475
+ assert.strictEqual(result.success, false);
1476
+ assert.ok(result.error.includes('not found at'));
1477
+ assert.ok(result.error.includes('check the path in REPOS.md or clone the repo there'));
1478
+ });
1479
+
1480
+ it('accepts valid ../repo path with .git', () => {
1481
+ const productRoot = path.join(tmpDir, 'product-root');
1482
+ const result = runCmdReposAdd(productRoot, '../my-repo', {});
1483
+ assert.strictEqual(result.success, true);
1484
+ });
1485
+ });
1486
+
1487
+ // ─── cmdReposAdd trailing slash normalization ────────────────────────────────
1488
+
1489
+ describe('cmdReposAdd trailing slash normalization', () => {
1490
+ let tmpDir;
1491
+ const { execSync } = require('child_process');
1492
+
1493
+ beforeEach(() => {
1494
+ tmpDir = createTempDir();
1495
+ const productRoot = path.join(tmpDir, 'product-root');
1496
+ fs.mkdirSync(path.join(productRoot, '.planning'), { recursive: true });
1497
+ const reposMd = `# Repos
1498
+
1499
+ Registered repositories for this product. Managed by DGS — manual edits may be overwritten.
1500
+
1501
+ | Name | Path | GitHub URL | Description |
1502
+ |------|------|------------|-------------|
1503
+ `;
1504
+ fs.writeFileSync(path.join(productRoot, '.planning', 'REPOS.md'), reposMd);
1505
+ // Create sibling repo with .git
1506
+ const siblingRepo = path.join(tmpDir, 'sibling-repo');
1507
+ fs.mkdirSync(path.join(siblingRepo, '.git'), { recursive: true });
1508
+ });
1509
+ afterEach(() => { cleanupDir(tmpDir); });
1510
+
1511
+ function runCmdReposAdd(cwd, repoPath, opts) {
1512
+ const reposPath = require.resolve('./repos.cjs');
1513
+ const script = `
1514
+ const repos = require('${reposPath.replace(/\\/g, '\\\\')}');
1515
+ let exitMsg = null;
1516
+ let exitCode = null;
1517
+ const origExit = process.exit;
1518
+ process.exit = (code) => { exitCode = code; throw new Error('EXIT:' + code + ':' + exitMsg); };
1519
+ const origStderr = process.stderr.write;
1520
+ process.stderr.write = (data) => { exitMsg = data; };
1521
+ const origStdout = process.stdout.write;
1522
+ let stdoutData = '';
1523
+ process.stdout.write = (data) => { stdoutData += data; };
1524
+ try {
1525
+ repos.cmdReposAdd('${cwd.replace(/\\/g, '\\\\')}', '${repoPath}', ${JSON.stringify(opts || {})}, true);
1526
+ process.stdout.write = origStdout;
1527
+ console.log(JSON.stringify({success: true, output: stdoutData}));
1528
+ } catch (e) {
1529
+ process.stderr.write = origStderr;
1530
+ process.stdout.write = origStdout;
1531
+ process.exit = origExit;
1532
+ if (exitCode === 0) {
1533
+ console.log(JSON.stringify({success: true, output: stdoutData}));
1534
+ } else {
1535
+ console.log(JSON.stringify({success: false, error: e.message}));
1536
+ }
1537
+ }
1538
+ `;
1539
+ const result = execSync(`node -e '${script.replace(/'/g, "'\\''")}'`, { encoding: 'utf-8', timeout: 5000 }).trim();
1540
+ return JSON.parse(result);
1541
+ }
1542
+
1543
+ function getStoredPath(productRoot) {
1544
+ const content = fs.readFileSync(path.join(productRoot, '.planning', 'REPOS.md'), 'utf-8');
1545
+ const lines = content.split('\n').filter(l => l.startsWith('|') && !l.includes('---') && !l.includes('Name'));
1546
+ if (lines.length === 0) return null;
1547
+ const cols = lines[0].split('|').map(c => c.trim()).filter(Boolean);
1548
+ return cols[1]; // Path column
1549
+ }
1550
+
1551
+ it('strips single trailing slash from ../repo/', () => {
1552
+ const productRoot = path.join(tmpDir, 'product-root');
1553
+ const result = runCmdReposAdd(productRoot, '../sibling-repo/', {});
1554
+ assert.strictEqual(result.success, true);
1555
+ const storedPath = getStoredPath(productRoot);
1556
+ assert.strictEqual(storedPath, '../sibling-repo');
1557
+ });
1558
+
1559
+ it('strips multiple trailing slashes from ../repo///', () => {
1560
+ const productRoot = path.join(tmpDir, 'product-root');
1561
+ const result = runCmdReposAdd(productRoot, '../sibling-repo///', {});
1562
+ assert.strictEqual(result.success, true);
1563
+ const storedPath = getStoredPath(productRoot);
1564
+ assert.strictEqual(storedPath, '../sibling-repo');
1565
+ });
1566
+
1567
+ it('works normally without trailing slash', () => {
1568
+ const productRoot = path.join(tmpDir, 'product-root');
1569
+ const result = runCmdReposAdd(productRoot, '../sibling-repo', {});
1570
+ assert.strictEqual(result.success, true);
1571
+ const storedPath = getStoredPath(productRoot);
1572
+ assert.strictEqual(storedPath, '../sibling-repo');
1573
+ });
1574
+ });
1575
+
1576
+ // ─── validateReposMdEager ────────────────────────────────────────────────────
1577
+
1578
+ describe('validateReposMdEager', () => {
1579
+ let tmpDir;
1580
+ beforeEach(() => { tmpDir = createTempDir(); });
1581
+ afterEach(() => { cleanupDir(tmpDir); });
1582
+
1583
+ function setupFlatLayout(tmpDir, entries) {
1584
+ const productRoot = path.join(tmpDir, 'product-root');
1585
+ fs.mkdirSync(path.join(productRoot, '.planning'), { recursive: true });
1586
+
1587
+ let reposMd = `# Repos
1588
+
1589
+ Registered repositories for this product. Managed by DGS — manual edits may be overwritten.
1590
+
1591
+ | Name | Path | GitHub URL | Description |
1592
+ |------|------|------------|-------------|
1593
+ `;
1594
+ for (const entry of entries) {
1595
+ reposMd += `| ${entry.name} | ${entry.path} | | Test repo |\n`;
1596
+ if (entry.createDir) {
1597
+ fs.mkdirSync(path.join(tmpDir, entry.name), { recursive: true });
1598
+ }
1599
+ if (entry.createGit) {
1600
+ fs.mkdirSync(path.join(tmpDir, entry.name, '.git'), { recursive: true });
1601
+ }
1602
+ }
1603
+ fs.writeFileSync(path.join(productRoot, '.planning', 'REPOS.md'), reposMd);
1604
+ return productRoot;
1605
+ }
1606
+
1607
+ it('detects missing directory', () => {
1608
+ const cwd = setupFlatLayout(tmpDir, [
1609
+ { name: 'ghost-repo', path: '../ghost-repo', createDir: false, createGit: false },
1610
+ ]);
1611
+ const result = validateReposMdEager(cwd);
1612
+ assert.ok(result.errors.length > 0);
1613
+ assert.ok(result.errors[0].includes('not found at'));
1614
+ });
1615
+
1616
+ it('detects missing .git', () => {
1617
+ const cwd = setupFlatLayout(tmpDir, [
1618
+ { name: 'no-git', path: '../no-git', createDir: true, createGit: false },
1619
+ ]);
1620
+ const result = validateReposMdEager(cwd);
1621
+ assert.ok(result.errors.length > 0);
1622
+ assert.ok(result.errors[0].includes('not a git repository'));
1623
+ });
1624
+
1625
+ it('detects duplicate absolute paths', () => {
1626
+ // Create a repo that will be referenced by two different names
1627
+ const repoDir = path.join(tmpDir, 'shared');
1628
+ fs.mkdirSync(path.join(repoDir, '.git'), { recursive: true });
1629
+ const productRoot = path.join(tmpDir, 'product-root');
1630
+ fs.mkdirSync(path.join(productRoot, '.planning'), { recursive: true });
1631
+
1632
+ const reposMd = `# Repos
1633
+
1634
+ Registered repositories for this product. Managed by DGS — manual edits may be overwritten.
1635
+
1636
+ | Name | Path | GitHub URL | Description |
1637
+ |------|------|------------|-------------|
1638
+ | shared-a | ../shared | | First ref |
1639
+ | shared-b | ../shared | | Duplicate ref |
1640
+ `;
1641
+ fs.writeFileSync(path.join(productRoot, '.planning', 'REPOS.md'), reposMd);
1642
+ const result = validateReposMdEager(productRoot);
1643
+ assert.ok(result.errors.length > 0);
1644
+ assert.ok(result.errors[0].includes('Duplicate path'));
1645
+ });
1646
+
1647
+ it('passes for valid ../repo entries', () => {
1648
+ const cwd = setupFlatLayout(tmpDir, [
1649
+ { name: 'valid-repo', path: '../valid-repo', createDir: true, createGit: true },
1650
+ ]);
1651
+ const result = validateReposMdEager(cwd);
1652
+ assert.strictEqual(result.errors.length, 0);
1653
+ assert.strictEqual(result.repos.length, 1);
1654
+ assert.strictEqual(result.repos[0].name, 'valid-repo');
1655
+ });
1656
+
1657
+ it('rejects ./repo path format', () => {
1658
+ const productRoot = path.join(tmpDir, 'product-root');
1659
+ fs.mkdirSync(path.join(productRoot, '.planning'), { recursive: true });
1660
+ fs.mkdirSync(path.join(productRoot, 'local-repo', '.git'), { recursive: true });
1661
+
1662
+ const reposMd = `# Repos
1663
+
1664
+ | Name | Path | GitHub URL | Description |
1665
+ |------|------|------------|-------------|
1666
+ | local-repo | ./local-repo | | Local repo |
1667
+ `;
1668
+ fs.writeFileSync(path.join(productRoot, '.planning', 'REPOS.md'), reposMd);
1669
+ const result = validateReposMdEager(productRoot);
1670
+ assert.ok(result.errors.length > 0);
1671
+ assert.ok(result.errors[0].includes('Repo paths must use ../name format'));
1672
+ });
1673
+ });
1674
+
1675
+ // ─── validateRepoPaths with ../repo paths ────────────────────────────────────
1676
+
1677
+ describe('validateRepoPaths with ../repo paths', () => {
1678
+ let tmpDir;
1679
+ beforeEach(() => { tmpDir = createTempDir(); });
1680
+ afterEach(() => { cleanupDir(tmpDir); });
1681
+
1682
+ it('validates ../repo paths that exist on disk', () => {
1683
+ const productRoot = path.join(tmpDir, 'product-root');
1684
+ fs.mkdirSync(productRoot, { recursive: true });
1685
+ fs.mkdirSync(path.join(tmpDir, 'sibling-repo'), { recursive: true });
1686
+
1687
+ const repos = [{ name: 'sibling-repo', path: '../sibling-repo' }];
1688
+ const result = validateRepoPaths(productRoot, repos);
1689
+ assert.strictEqual(result.valid.length, 1);
1690
+ assert.strictEqual(result.missing.length, 0);
1691
+ });
1692
+
1693
+ it('detects missing ../repo paths', () => {
1694
+ const productRoot = path.join(tmpDir, 'product-root');
1695
+ fs.mkdirSync(productRoot, { recursive: true });
1696
+
1697
+ const repos = [{ name: 'gone-repo', path: '../gone-repo' }];
1698
+ const result = validateRepoPaths(productRoot, repos);
1699
+ assert.strictEqual(result.valid.length, 0);
1700
+ assert.strictEqual(result.missing.length, 1);
1701
+ });
1702
+ });
1703
+
1704
+ // ─── syncGitignore skip for flat layout ──────────────────────────────────────
1705
+
1706
+ describe('syncGitignore skip for flat layout', () => {
1707
+ let tmpDir;
1708
+ const { execSync } = require('child_process');
1709
+
1710
+ beforeEach(() => { tmpDir = createTempDir(); });
1711
+ afterEach(() => { cleanupDir(tmpDir); });
1712
+
1713
+ it('cmdReposInitProduct with no local repos does NOT create .gitignore DGS section', () => {
1714
+ // Set up a product root with no subdirectory repos (flat layout)
1715
+ // discoverRepos will find nothing -> localPaths is empty -> syncGitignore not called
1716
+ const productRoot = path.join(tmpDir, 'product-root');
1717
+ fs.mkdirSync(path.join(productRoot, '.planning'), { recursive: true });
1718
+ // Create sibling repos (outside product root — discoverRepos won't find them)
1719
+ fs.mkdirSync(path.join(tmpDir, 'sibling-app', '.git'), { recursive: true });
1720
+
1721
+ const reposPath = require.resolve('./repos.cjs');
1722
+ const script = `
1723
+ const repos = require('${reposPath.replace(/\\/g, '\\\\')}');
1724
+ const origExit = process.exit;
1725
+ process.exit = (code) => { throw new Error('EXIT:' + code); };
1726
+ const origStdout = process.stdout.write;
1727
+ let stdoutData = '';
1728
+ process.stdout.write = (data) => { stdoutData += data; };
1729
+ try {
1730
+ repos.cmdReposInitProduct('${productRoot.replace(/\\/g, '\\\\')}', {}, true);
1731
+ process.stdout.write = origStdout;
1732
+ console.log(stdoutData);
1733
+ } catch (e) {
1734
+ process.stdout.write = origStdout;
1735
+ process.exit = origExit;
1736
+ if (stdoutData) {
1737
+ console.log(stdoutData);
1738
+ } else {
1739
+ console.log(JSON.stringify({error: e.message}));
1740
+ }
1741
+ }
1742
+ `;
1743
+ const result = execSync(`node -e '${script.replace(/'/g, "'\\''")}'`, {
1744
+ encoding: 'utf-8', timeout: 5000,
1745
+ }).trim();
1746
+ const parsed = JSON.parse(result);
1747
+ assert.strictEqual(parsed.initialized, true);
1748
+ assert.strictEqual(parsed.gitignore_synced, false);
1749
+
1750
+ // .gitignore should not exist or should not contain DGS markers
1751
+ const gitignorePath = path.join(productRoot, '.gitignore');
1752
+ if (fs.existsSync(gitignorePath)) {
1753
+ const content = fs.readFileSync(gitignorePath, 'utf-8');
1754
+ assert.ok(!content.includes('# DGS managed repos'), '.gitignore should not contain DGS markers');
1755
+ }
1756
+ });
1757
+
1758
+ it('cmdReposAdd with ../path does NOT modify .gitignore', () => {
1759
+ // Set up product root with REPOS.md and a sibling repo
1760
+ const productRoot = path.join(tmpDir, 'product-root');
1761
+ fs.mkdirSync(path.join(productRoot, '.planning'), { recursive: true });
1762
+ const reposMd = `# Repos
1763
+
1764
+ Registered repositories for this product. Managed by DGS — manual edits may be overwritten.
1765
+
1766
+ | Name | Path | GitHub URL | Description |
1767
+ |------|------|------------|-------------|
1768
+ `;
1769
+ fs.writeFileSync(path.join(productRoot, '.planning', 'REPOS.md'), reposMd);
1770
+ // Create sibling repo with .git
1771
+ fs.mkdirSync(path.join(tmpDir, 'sibling-repo', '.git'), { recursive: true });
1772
+
1773
+ // Create an initial .gitignore with user content
1774
+ fs.writeFileSync(path.join(productRoot, '.gitignore'), 'node_modules/\n.env\n');
1775
+ const beforeContent = fs.readFileSync(path.join(productRoot, '.gitignore'), 'utf-8');
1776
+
1777
+ const reposPath = require.resolve('./repos.cjs');
1778
+ const script = `
1779
+ const repos = require('${reposPath.replace(/\\/g, '\\\\')}');
1780
+ const origExit = process.exit;
1781
+ process.exit = (code) => { throw new Error('EXIT:' + code); };
1782
+ const origStdout = process.stdout.write;
1783
+ let stdoutData = '';
1784
+ process.stdout.write = (data) => { stdoutData += data; };
1785
+ try {
1786
+ repos.cmdReposAdd('${productRoot.replace(/\\/g, '\\\\')}', '../sibling-repo', {}, true);
1787
+ process.stdout.write = origStdout;
1788
+ console.log(stdoutData);
1789
+ } catch (e) {
1790
+ process.stdout.write = origStdout;
1791
+ process.exit = origExit;
1792
+ console.log(JSON.stringify({error: e.message}));
1793
+ }
1794
+ `;
1795
+ execSync(`node -e '${script.replace(/'/g, "'\\''")}'`, {
1796
+ encoding: 'utf-8', timeout: 5000,
1797
+ });
1798
+
1799
+ // .gitignore should be unchanged (no DGS markers added)
1800
+ const afterContent = fs.readFileSync(path.join(productRoot, '.gitignore'), 'utf-8');
1801
+ assert.strictEqual(afterContent, beforeContent, '.gitignore should not be modified for ../repo paths');
1802
+ assert.ok(!afterContent.includes('# DGS managed repos'), '.gitignore should not contain DGS markers');
1803
+ });
1804
+
1805
+ it('cmdReposRemove with ../path does NOT modify .gitignore', () => {
1806
+ // Set up product root with REPOS.md containing a ../repo entry
1807
+ const productRoot = path.join(tmpDir, 'product-root');
1808
+ fs.mkdirSync(path.join(productRoot, '.planning'), { recursive: true });
1809
+ const reposMd = `# Repos
1810
+
1811
+ Registered repositories for this product. Managed by DGS — manual edits may be overwritten.
1812
+
1813
+ | Name | Path | GitHub URL | Description |
1814
+ |------|------|------------|-------------|
1815
+ | sibling-repo | ../sibling-repo | | Test repo |
1816
+ `;
1817
+ fs.writeFileSync(path.join(productRoot, '.planning', 'REPOS.md'), reposMd);
1818
+ // Create sibling repo with .git
1819
+ fs.mkdirSync(path.join(tmpDir, 'sibling-repo', '.git'), { recursive: true });
1820
+
1821
+ // Create an initial .gitignore with user content
1822
+ fs.writeFileSync(path.join(productRoot, '.gitignore'), 'node_modules/\n.env\n');
1823
+ const beforeContent = fs.readFileSync(path.join(productRoot, '.gitignore'), 'utf-8');
1824
+
1825
+ const reposPath = require.resolve('./repos.cjs');
1826
+ const script = `
1827
+ const repos = require('${reposPath.replace(/\\/g, '\\\\')}');
1828
+ const origExit = process.exit;
1829
+ process.exit = (code) => { throw new Error('EXIT:' + code); };
1830
+ const origStdout = process.stdout.write;
1831
+ let stdoutData = '';
1832
+ process.stdout.write = (data) => { stdoutData += data; };
1833
+ try {
1834
+ repos.cmdReposRemove('${productRoot.replace(/\\/g, '\\\\')}', 'sibling-repo', { force: true }, true);
1835
+ process.stdout.write = origStdout;
1836
+ console.log(stdoutData);
1837
+ } catch (e) {
1838
+ process.stdout.write = origStdout;
1839
+ process.exit = origExit;
1840
+ console.log(JSON.stringify({error: e.message}));
1841
+ }
1842
+ `;
1843
+ execSync(`node -e '${script.replace(/'/g, "'\\''")}'`, {
1844
+ encoding: 'utf-8', timeout: 5000,
1845
+ });
1846
+
1847
+ // .gitignore should be unchanged
1848
+ const afterContent = fs.readFileSync(path.join(productRoot, '.gitignore'), 'utf-8');
1849
+ assert.strictEqual(afterContent, beforeContent, '.gitignore should not be modified when removing ../repo paths');
1850
+ assert.ok(!afterContent.includes('# DGS managed repos'), '.gitignore should not contain DGS markers');
1851
+ });
1852
+
1853
+ it('syncGitignore silently filters out ../paths passed directly', () => {
1854
+ // Even if a caller passes ../paths directly to syncGitignore,
1855
+ // they should be filtered out (belt-and-suspenders defense)
1856
+ const productRoot = path.join(tmpDir, 'product-root');
1857
+ fs.mkdirSync(productRoot, { recursive: true });
1858
+ fs.writeFileSync(path.join(productRoot, '.gitignore'), 'node_modules/\n');
1859
+
1860
+ // Pass only ../paths - should result in no DGS section at all
1861
+ syncGitignore(productRoot, ['../sibling-a', '../sibling-b']);
1862
+
1863
+ const content = fs.readFileSync(path.join(productRoot, '.gitignore'), 'utf-8');
1864
+ assert.strictEqual(content, 'node_modules/\n', '.gitignore should be unchanged');
1865
+ assert.ok(!content.includes('# DGS managed repos'), 'No DGS markers should be added');
1866
+ });
1867
+
1868
+ it('syncGitignore filters ../paths but keeps ./paths in mixed input', () => {
1869
+ const productRoot = path.join(tmpDir, 'product-root');
1870
+ fs.mkdirSync(productRoot, { recursive: true });
1871
+ fs.writeFileSync(path.join(productRoot, '.gitignore'), 'node_modules/\n');
1872
+
1873
+ // Pass mix of ./paths and ../paths - only ./paths should appear
1874
+ syncGitignore(productRoot, ['./local-repo', '../sibling-a', './another-local']);
1875
+
1876
+ const content = fs.readFileSync(path.join(productRoot, '.gitignore'), 'utf-8');
1877
+ assert.ok(content.includes('local-repo/'), 'Should contain local-repo');
1878
+ assert.ok(content.includes('another-local/'), 'Should contain another-local');
1879
+ assert.ok(!content.includes('sibling-a'), 'Should NOT contain sibling-a');
1880
+ assert.ok(content.includes('# DGS managed repos'), 'DGS markers should exist for local repos');
1881
+ });
1882
+ });