project-roadmap-tracking 0.1.0 → 0.2.1

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 (58) hide show
  1. package/README.md +293 -24
  2. package/dist/commands/add.d.ts +2 -0
  3. package/dist/commands/add.js +39 -31
  4. package/dist/commands/complete.d.ts +2 -0
  5. package/dist/commands/complete.js +35 -12
  6. package/dist/commands/init.d.ts +1 -0
  7. package/dist/commands/init.js +63 -46
  8. package/dist/commands/list.d.ts +3 -0
  9. package/dist/commands/list.js +65 -62
  10. package/dist/commands/pass-test.d.ts +4 -1
  11. package/dist/commands/pass-test.js +36 -13
  12. package/dist/commands/show.d.ts +4 -1
  13. package/dist/commands/show.js +38 -59
  14. package/dist/commands/update.d.ts +3 -0
  15. package/dist/commands/update.js +77 -32
  16. package/dist/commands/validate.d.ts +4 -1
  17. package/dist/commands/validate.js +74 -32
  18. package/dist/errors/base.error.d.ts +21 -0
  19. package/dist/errors/base.error.js +35 -0
  20. package/dist/errors/circular-dependency.error.d.ts +8 -0
  21. package/dist/errors/circular-dependency.error.js +13 -0
  22. package/dist/errors/config-not-found.error.d.ts +7 -0
  23. package/dist/errors/config-not-found.error.js +12 -0
  24. package/dist/errors/index.d.ts +16 -0
  25. package/dist/errors/index.js +26 -0
  26. package/dist/errors/invalid-task.error.d.ts +7 -0
  27. package/dist/errors/invalid-task.error.js +12 -0
  28. package/dist/errors/roadmap-not-found.error.d.ts +7 -0
  29. package/dist/errors/roadmap-not-found.error.js +12 -0
  30. package/dist/errors/task-not-found.error.d.ts +7 -0
  31. package/dist/errors/task-not-found.error.js +12 -0
  32. package/dist/errors/validation.error.d.ts +16 -0
  33. package/dist/errors/validation.error.js +16 -0
  34. package/dist/repositories/config.repository.d.ts +76 -0
  35. package/dist/repositories/config.repository.js +282 -0
  36. package/dist/repositories/index.d.ts +2 -0
  37. package/dist/repositories/index.js +2 -0
  38. package/dist/repositories/roadmap.repository.d.ts +82 -0
  39. package/dist/repositories/roadmap.repository.js +201 -0
  40. package/dist/services/display.service.d.ts +182 -0
  41. package/dist/services/display.service.js +320 -0
  42. package/dist/services/error-handler.service.d.ts +114 -0
  43. package/dist/services/error-handler.service.js +169 -0
  44. package/dist/services/roadmap.service.d.ts +142 -0
  45. package/dist/services/roadmap.service.js +269 -0
  46. package/dist/services/task-dependency.service.d.ts +210 -0
  47. package/dist/services/task-dependency.service.js +371 -0
  48. package/dist/services/task-query.service.d.ts +123 -0
  49. package/dist/services/task-query.service.js +259 -0
  50. package/dist/services/task.service.d.ts +155 -0
  51. package/dist/services/task.service.js +233 -0
  52. package/dist/util/read-config.js +12 -2
  53. package/dist/util/read-roadmap.js +12 -2
  54. package/dist/util/types.d.ts +5 -0
  55. package/dist/util/update-task.js +2 -1
  56. package/dist/util/validate-task.js +6 -5
  57. package/oclif.manifest.json +128 -5
  58. package/package.json +28 -4
@@ -0,0 +1,282 @@
1
+ import Ajv from 'ajv';
2
+ import { readFile, stat } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { ConfigNotFoundError, ValidationError } from '../errors/index.js';
6
+ /**
7
+ * ConfigRepository provides caching, validation, and inheritance for config files.
8
+ * Features:
9
+ * - In-memory cache with mtime-based invalidation
10
+ * - Multi-level config inheritance (project → user → global)
11
+ * - JSON schema validation using schemas/config/v1.1.json
12
+ * - Shallow merge strategy for inherited configs
13
+ */
14
+ export class ConfigRepository {
15
+ static configSchema = {
16
+ $schema: 'http://json-schema.org/draft-07/schema#',
17
+ additionalProperties: false,
18
+ properties: {
19
+ $schema: {
20
+ description: 'Reference to the JSON schema for validation',
21
+ type: 'string',
22
+ },
23
+ cache: {
24
+ additionalProperties: false,
25
+ description: 'Caching configuration for the roadmap repository',
26
+ properties: {
27
+ enabled: {
28
+ default: true,
29
+ description: 'Enable in-memory caching of roadmap data',
30
+ type: 'boolean',
31
+ },
32
+ maxSize: {
33
+ default: 10,
34
+ description: 'Maximum number of roadmaps to cache (LRU eviction)',
35
+ minimum: 1,
36
+ type: 'number',
37
+ },
38
+ watchFiles: {
39
+ default: true,
40
+ description: 'Watch roadmap files for external changes and auto-invalidate cache',
41
+ type: 'boolean',
42
+ },
43
+ },
44
+ type: 'object',
45
+ },
46
+ metadata: {
47
+ additionalProperties: false,
48
+ description: 'Metadata about the project roadmap',
49
+ properties: {
50
+ description: {
51
+ description: 'Description of the project roadmap',
52
+ type: 'string',
53
+ },
54
+ name: {
55
+ description: 'Name of the project roadmap',
56
+ type: 'string',
57
+ },
58
+ },
59
+ type: 'object',
60
+ },
61
+ path: {
62
+ default: './prt.json',
63
+ description: 'Path to the prt.json roadmap file',
64
+ type: 'string',
65
+ },
66
+ },
67
+ required: ['path'],
68
+ type: 'object',
69
+ };
70
+ cache = null;
71
+ config;
72
+ validateSchema;
73
+ constructor(config) {
74
+ this.config = {
75
+ cacheEnabled: config?.cacheEnabled ?? true,
76
+ searchPaths: config?.searchPaths ?? this.getDefaultSearchPaths(),
77
+ };
78
+ // Initialize JSON schema validator
79
+ const ajv = new Ajv({ allErrors: true });
80
+ this.validateSchema = ajv.compile(ConfigRepository.configSchema);
81
+ }
82
+ /**
83
+ * Get the currently cached config (if any)
84
+ */
85
+ getCachedConfig() {
86
+ return this.cache?.data ?? null;
87
+ }
88
+ /**
89
+ * Clear the in-memory cache
90
+ */
91
+ invalidateCache() {
92
+ this.cache = null;
93
+ }
94
+ /**
95
+ * Load config with caching and inheritance
96
+ * Searches for config files in order: project → user → global
97
+ * Merges configs with shallow merge (project overrides user overrides global)
98
+ */
99
+ async load() {
100
+ // Check cache if enabled
101
+ if (this.config.cacheEnabled && this.cache) {
102
+ const isValid = await this.isCacheValid();
103
+ if (isValid) {
104
+ return this.cache.data;
105
+ }
106
+ }
107
+ // Load and merge configs from all search paths
108
+ const configs = await this.loadAllConfigs();
109
+ if (configs.length === 0) {
110
+ throw new ConfigNotFoundError('.prtrc.json');
111
+ }
112
+ // Shallow merge: project overrides user overrides global
113
+ const merged = this.mergeConfigs(configs);
114
+ // Validate merged config
115
+ this.validateConfig(merged);
116
+ // Cache the result
117
+ if (this.config.cacheEnabled) {
118
+ const stats = await stat(configs[0].path); // Use mtime of project-level config
119
+ this.cache = {
120
+ data: merged,
121
+ mtime: stats.mtimeMs,
122
+ path: configs[0].path,
123
+ };
124
+ }
125
+ return merged;
126
+ }
127
+ /**
128
+ * Reload config from disk, bypassing cache
129
+ */
130
+ async reload() {
131
+ this.invalidateCache();
132
+ return this.load();
133
+ }
134
+ /**
135
+ * Get default config file search paths
136
+ * Order: project → user → global
137
+ */
138
+ getDefaultSearchPaths() {
139
+ const paths = [
140
+ '.prtrc.json', // Project level (current directory)
141
+ join(homedir(), '.prtrc.json'), // User level
142
+ ];
143
+ // Global level (Unix-like systems only)
144
+ if (process.platform !== 'win32') {
145
+ paths.push('/etc/prt/.prtrc.json');
146
+ }
147
+ return paths;
148
+ }
149
+ /**
150
+ * Check if cached config is still valid by comparing mtime
151
+ */
152
+ async isCacheValid() {
153
+ if (!this.cache) {
154
+ return false;
155
+ }
156
+ try {
157
+ const stats = await stat(this.cache.path);
158
+ return stats.mtimeMs === this.cache.mtime;
159
+ }
160
+ catch {
161
+ // If stat fails, cache is invalid
162
+ return false;
163
+ }
164
+ }
165
+ /**
166
+ * Load all available configs from search paths
167
+ * Returns array in order of precedence (project first, global last)
168
+ */
169
+ async loadAllConfigs() {
170
+ const paths = this.config.searchPaths ?? [];
171
+ const configPromises = paths.map(async (path) => {
172
+ const config = await this.loadConfigFromPath(path);
173
+ return config ? { config, path } : null;
174
+ });
175
+ const results = await Promise.all(configPromises);
176
+ return results.filter((result) => result !== null);
177
+ }
178
+ /**
179
+ * Load config from a specific path
180
+ */
181
+ async loadConfigFromPath(path) {
182
+ try {
183
+ const data = await readFile(path, 'utf8');
184
+ const config = JSON.parse(data);
185
+ return config;
186
+ }
187
+ catch (error) {
188
+ // Re-throw SyntaxError for JSON parsing issues
189
+ if (error instanceof SyntaxError) {
190
+ throw error;
191
+ }
192
+ // Return null for file not found (not an error during inheritance search)
193
+ return null;
194
+ }
195
+ }
196
+ /**
197
+ * Merge configs using shallow merge strategy
198
+ * Project-level config takes precedence over user-level over global-level
199
+ */
200
+ mergeConfigs(configs) {
201
+ if (configs.length === 0) {
202
+ throw new ConfigNotFoundError('.prtrc.json');
203
+ }
204
+ // Start with the lowest precedence config (last in array)
205
+ const lastConfig = configs.at(-1);
206
+ if (!lastConfig) {
207
+ throw new ConfigNotFoundError('.prtrc.json');
208
+ }
209
+ let merged = { ...lastConfig.config };
210
+ // Apply each higher-precedence config (working backwards)
211
+ for (let i = configs.length - 2; i >= 0; i--) {
212
+ merged = {
213
+ ...merged,
214
+ ...configs[i].config,
215
+ // Deep merge for nested objects
216
+ cache: configs[i].config.cache ? { ...merged.cache, ...configs[i].config.cache } : merged.cache,
217
+ metadata: configs[i].config.metadata ? { ...merged.metadata, ...configs[i].config.metadata } : merged.metadata,
218
+ };
219
+ }
220
+ return merged;
221
+ }
222
+ /**
223
+ * Validate config against JSON schema
224
+ */
225
+ validateConfig(config) {
226
+ try {
227
+ const valid = this.validateSchema(config);
228
+ if (!valid) {
229
+ // Handle case where validateSchema.errors might be null/undefined
230
+ if (!this.validateSchema.errors || !Array.isArray(this.validateSchema.errors)) {
231
+ throw new ValidationError([
232
+ {
233
+ field: 'config',
234
+ message: 'Schema validation failed',
235
+ type: 'structure',
236
+ },
237
+ ]);
238
+ }
239
+ const errorDetails = this.validateSchema.errors.map((err) => ({
240
+ field: err.dataPath || 'config',
241
+ message: err.message || 'Unknown error',
242
+ type: 'structure',
243
+ }));
244
+ throw new ValidationError(errorDetails);
245
+ }
246
+ }
247
+ catch (error) {
248
+ // If it's already a ValidationError, re-throw it
249
+ if (error instanceof ValidationError) {
250
+ throw error;
251
+ }
252
+ // Wrap any other error (like TypeError) in ValidationError
253
+ throw new ValidationError([
254
+ {
255
+ field: 'config',
256
+ message: error instanceof Error ? error.message : String(error),
257
+ type: 'structure',
258
+ },
259
+ ]);
260
+ }
261
+ }
262
+ }
263
+ // Singleton instance with default configuration
264
+ let defaultInstance = null;
265
+ /**
266
+ * Get the default config repository instance
267
+ */
268
+ export function getDefaultConfigRepository() {
269
+ if (!defaultInstance) {
270
+ defaultInstance = new ConfigRepository();
271
+ }
272
+ return defaultInstance;
273
+ }
274
+ /**
275
+ * Reset the default config repository instance (useful for testing)
276
+ */
277
+ export function resetDefaultConfigRepository() {
278
+ if (defaultInstance) {
279
+ defaultInstance.invalidateCache();
280
+ defaultInstance = null;
281
+ }
282
+ }
@@ -0,0 +1,2 @@
1
+ export * from './config.repository.js';
2
+ export * from './roadmap.repository.js';
@@ -0,0 +1,2 @@
1
+ export * from './config.repository.js';
2
+ export * from './roadmap.repository.js';
@@ -0,0 +1,82 @@
1
+ import { Config, Roadmap } from '../util/types.js';
2
+ /**
3
+ * Configuration for RoadmapRepository
4
+ */
5
+ export interface RepositoryConfig {
6
+ cacheEnabled?: boolean;
7
+ maxCacheSize?: number;
8
+ watchFiles?: boolean;
9
+ }
10
+ /**
11
+ * RoadmapRepository provides caching and file watching for roadmap data.
12
+ * Features:
13
+ * - In-memory LRU cache with configurable size
14
+ * - Automatic cache invalidation on writes
15
+ * - File system watching for external changes (using chokidar)
16
+ * - Configuration via .prtrc.json cache settings
17
+ */
18
+ export declare class RoadmapRepository {
19
+ private cache;
20
+ private config;
21
+ private watchers;
22
+ constructor(config?: RepositoryConfig);
23
+ /**
24
+ * Create a repository instance from a Config object
25
+ */
26
+ static fromConfig(config: Config): RoadmapRepository;
27
+ /**
28
+ * Stop all file watchers and clear cache
29
+ */
30
+ dispose(): Promise<void>;
31
+ /**
32
+ * Get current cache size
33
+ */
34
+ getCacheSize(): number;
35
+ /**
36
+ * Invalidate cache for a specific path
37
+ * @param path - Path to invalidate
38
+ */
39
+ invalidate(path: string): void;
40
+ /**
41
+ * Invalidate all cached roadmaps
42
+ */
43
+ invalidateAll(): void;
44
+ /**
45
+ * Check if a path is cached
46
+ */
47
+ isCached(path: string): boolean;
48
+ /**
49
+ * Load a roadmap from the file system with caching
50
+ * @param path - Path to the roadmap file
51
+ * @returns The loaded roadmap
52
+ */
53
+ load(path: string): Promise<Roadmap>;
54
+ /**
55
+ * Save a roadmap to the file system and update cache
56
+ * @param path - Path to the roadmap file
57
+ * @param roadmap - The roadmap to save
58
+ */
59
+ save(path: string, roadmap: Roadmap): Promise<void>;
60
+ /**
61
+ * Add entry to cache with LRU eviction
62
+ */
63
+ private addToCache;
64
+ /**
65
+ * Load roadmap from disk without caching
66
+ */
67
+ private loadFromDisk;
68
+ /**
69
+ * Set up file watcher for a path
70
+ */
71
+ private setupWatcher;
72
+ }
73
+ /**
74
+ * Get the default repository instance
75
+ */
76
+ export declare function getDefaultRepository(): RoadmapRepository;
77
+ /**
78
+ * Reset the default repository instance (useful for testing)
79
+ */
80
+ export declare function resetDefaultRepository(): void;
81
+ declare const _default: RoadmapRepository;
82
+ export default _default;
@@ -0,0 +1,201 @@
1
+ import { watch } from 'chokidar';
2
+ import { readFile, stat, writeFile } from 'node:fs/promises';
3
+ /**
4
+ * RoadmapRepository provides caching and file watching for roadmap data.
5
+ * Features:
6
+ * - In-memory LRU cache with configurable size
7
+ * - Automatic cache invalidation on writes
8
+ * - File system watching for external changes (using chokidar)
9
+ * - Configuration via .prtrc.json cache settings
10
+ */
11
+ export class RoadmapRepository {
12
+ cache = new Map();
13
+ config;
14
+ watchers = new Map();
15
+ constructor(config) {
16
+ this.config = {
17
+ cacheEnabled: config?.cacheEnabled ?? true,
18
+ maxCacheSize: config?.maxCacheSize ?? 10,
19
+ watchFiles: config?.watchFiles ?? true,
20
+ };
21
+ }
22
+ /**
23
+ * Create a repository instance from a Config object
24
+ */
25
+ static fromConfig(config) {
26
+ return new RoadmapRepository({
27
+ cacheEnabled: config.cache?.enabled ?? true,
28
+ maxCacheSize: config.cache?.maxSize ?? 10,
29
+ watchFiles: config.cache?.watchFiles ?? true,
30
+ });
31
+ }
32
+ /**
33
+ * Stop all file watchers and clear cache
34
+ */
35
+ async dispose() {
36
+ // Close all watchers
37
+ await Promise.all([...this.watchers.values()].map((watcher) => watcher.close()));
38
+ this.watchers.clear();
39
+ this.cache.clear();
40
+ }
41
+ /**
42
+ * Get current cache size
43
+ */
44
+ getCacheSize() {
45
+ return this.cache.size;
46
+ }
47
+ /**
48
+ * Invalidate cache for a specific path
49
+ * @param path - Path to invalidate
50
+ */
51
+ invalidate(path) {
52
+ this.cache.delete(path);
53
+ }
54
+ /**
55
+ * Invalidate all cached roadmaps
56
+ */
57
+ invalidateAll() {
58
+ this.cache.clear();
59
+ }
60
+ /**
61
+ * Check if a path is cached
62
+ */
63
+ isCached(path) {
64
+ return this.cache.has(path);
65
+ }
66
+ /**
67
+ * Load a roadmap from the file system with caching
68
+ * @param path - Path to the roadmap file
69
+ * @returns The loaded roadmap
70
+ */
71
+ async load(path) {
72
+ // Check if caching is disabled
73
+ if (!this.config.cacheEnabled) {
74
+ return this.loadFromDisk(path);
75
+ }
76
+ // Check if we have a cached version
77
+ const cached = this.cache.get(path);
78
+ if (cached) {
79
+ // Verify cache is still valid by checking mtime
80
+ try {
81
+ const stats = await stat(path);
82
+ const currentMtime = stats.mtimeMs;
83
+ if (currentMtime === cached.mtime) {
84
+ // Cache hit - move to end for LRU
85
+ this.cache.delete(path);
86
+ this.cache.set(path, cached);
87
+ return cached.data;
88
+ }
89
+ }
90
+ catch {
91
+ // If stat fails, invalidate cache and reload
92
+ this.invalidate(path);
93
+ }
94
+ }
95
+ // Cache miss or stale - load from disk
96
+ const roadmap = await this.loadFromDisk(path);
97
+ const stats = await stat(path);
98
+ // Add to cache with LRU eviction
99
+ this.addToCache(path, roadmap, stats.mtimeMs);
100
+ // Set up file watcher if enabled
101
+ if (this.config.watchFiles && !this.watchers.has(path)) {
102
+ this.setupWatcher(path);
103
+ }
104
+ return roadmap;
105
+ }
106
+ /**
107
+ * Save a roadmap to the file system and update cache
108
+ * @param path - Path to the roadmap file
109
+ * @param roadmap - The roadmap to save
110
+ */
111
+ async save(path, roadmap) {
112
+ await writeFile(path, JSON.stringify(roadmap, null, 2), 'utf8');
113
+ // Update cache if caching is enabled
114
+ if (this.config.cacheEnabled) {
115
+ const stats = await stat(path);
116
+ this.addToCache(path, roadmap, stats.mtimeMs);
117
+ }
118
+ }
119
+ /**
120
+ * Add entry to cache with LRU eviction
121
+ */
122
+ addToCache(path, data, mtime) {
123
+ // Remove old entry if exists (for LRU reordering)
124
+ if (this.cache.has(path)) {
125
+ this.cache.delete(path);
126
+ }
127
+ // Evict oldest entry if cache is full
128
+ if (this.cache.size >= (this.config.maxCacheSize ?? 10)) {
129
+ const firstKey = this.cache.keys().next().value;
130
+ if (firstKey) {
131
+ this.cache.delete(firstKey);
132
+ // Also stop watching evicted file
133
+ const watcher = this.watchers.get(firstKey);
134
+ if (watcher) {
135
+ watcher.close().catch(() => {
136
+ /* ignore close errors */
137
+ });
138
+ this.watchers.delete(firstKey);
139
+ }
140
+ }
141
+ }
142
+ // Add new entry
143
+ this.cache.set(path, { data, mtime, path });
144
+ }
145
+ /**
146
+ * Load roadmap from disk without caching
147
+ */
148
+ async loadFromDisk(path) {
149
+ const data = await readFile(path, 'utf8');
150
+ return JSON.parse(data);
151
+ }
152
+ /**
153
+ * Set up file watcher for a path
154
+ */
155
+ setupWatcher(path) {
156
+ const watcher = watch(path, {
157
+ awaitWriteFinish: {
158
+ pollInterval: 100,
159
+ stabilityThreshold: 250,
160
+ },
161
+ persistent: false,
162
+ });
163
+ watcher.on('change', () => {
164
+ this.invalidate(path);
165
+ });
166
+ watcher.on('unlink', () => {
167
+ this.invalidate(path);
168
+ this.watchers
169
+ .get(path)
170
+ ?.close()
171
+ .catch(() => {
172
+ /* ignore close errors */
173
+ });
174
+ this.watchers.delete(path);
175
+ });
176
+ this.watchers.set(path, watcher);
177
+ }
178
+ }
179
+ // Singleton instance with default configuration
180
+ let defaultInstance = null;
181
+ /**
182
+ * Get the default repository instance
183
+ */
184
+ export function getDefaultRepository() {
185
+ if (!defaultInstance) {
186
+ defaultInstance = new RoadmapRepository();
187
+ }
188
+ return defaultInstance;
189
+ }
190
+ /**
191
+ * Reset the default repository instance (useful for testing)
192
+ */
193
+ export function resetDefaultRepository() {
194
+ if (defaultInstance) {
195
+ defaultInstance.dispose().catch(() => {
196
+ /* ignore disposal errors */
197
+ });
198
+ defaultInstance = null;
199
+ }
200
+ }
201
+ export default getDefaultRepository();