docguard-cli 0.35.0 → 0.36.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 (55) hide show
  1. package/README.md +8 -15
  2. package/cli/commands/agent.mjs +27 -6
  3. package/cli/commands/ci.mjs +3 -0
  4. package/cli/commands/diagnose.mjs +8 -2
  5. package/cli/commands/feedback.mjs +83 -89
  6. package/cli/commands/fix.mjs +4 -0
  7. package/cli/commands/generate.mjs +3 -0
  8. package/cli/commands/guard.mjs +37 -20
  9. package/cli/commands/hooks.mjs +61 -40
  10. package/cli/commands/init.mjs +51 -5
  11. package/cli/commands/memory.mjs +29 -15
  12. package/cli/commands/report.mjs +12 -7
  13. package/cli/commands/score.mjs +39 -19
  14. package/cli/commands/sync.mjs +2 -0
  15. package/cli/commands/watch.mjs +113 -70
  16. package/cli/config.mjs +6 -3
  17. package/cli/docguard.mjs +12 -4
  18. package/cli/findings.mjs +13 -13
  19. package/cli/scanners/memory-plan.mjs +279 -134
  20. package/cli/scanners/project-type.mjs +6 -1
  21. package/cli/scanners/semantic-claims.mjs +176 -26
  22. package/cli/shared-diff.mjs +22 -1
  23. package/cli/shared-doc-roles.mjs +59 -0
  24. package/cli/shared-ignore.mjs +15 -2
  25. package/cli/shared-source.mjs +223 -1
  26. package/cli/validator-coverage.mjs +20 -0
  27. package/cli/validators/api-surface.mjs +94 -70
  28. package/cli/validators/architecture.mjs +19 -5
  29. package/cli/validators/diff-suspicion.mjs +45 -9
  30. package/cli/validators/docs-coverage.mjs +6 -5
  31. package/cli/validators/docs-diff.mjs +51 -7
  32. package/cli/validators/environment.mjs +3 -2
  33. package/cli/validators/freshness.mjs +140 -83
  34. package/cli/validators/schema-sync.mjs +3 -2
  35. package/cli/validators/security.mjs +58 -23
  36. package/cli/validators/structure.mjs +3 -1
  37. package/cli/validators/test-spec.mjs +3 -2
  38. package/cli/validators/todo-tracking.mjs +61 -28
  39. package/cli/validators/traceability.mjs +152 -38
  40. package/docs/configuration.md +41 -0
  41. package/extensions/spec-kit-docguard/README.md +6 -6
  42. package/extensions/spec-kit-docguard/commands/sync.md +1 -1
  43. package/extensions/spec-kit-docguard/extension.yml +3 -4
  44. package/extensions/spec-kit-docguard/scripts/bash/common.sh +9 -17
  45. package/extensions/spec-kit-docguard/scripts/bash/docguard-check-docs.sh +18 -11
  46. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +3 -3
  47. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
  48. package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
  49. package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
  50. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -2
  51. package/extensions/spec-kit-docguard/templates/extensions.yml +1 -2
  52. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +74 -29
  53. package/package.json +1 -1
  54. package/schemas/docguard-config.schema.json +43 -1
  55. package/templates/ci/github-actions.yml +51 -11
@@ -1,3 +1,4 @@
1
+ import { remapDocPath } from '../shared-doc-roles.mjs';
1
2
  /**
2
3
  * Memory Plan — the orchestration artifact behind AI-powered Generate.
3
4
  *
@@ -32,110 +33,263 @@ const md = {
32
33
  };
33
34
 
34
35
  /**
35
- * v0.15-P1: in-process cache (Map). buildMemoryPlan is expensive (~400ms on
36
- * an enterprise client project) because it triggers routes/schemas/screens/
37
- * frontend scanners all of which walk the source tree.
38
- *
39
- * v0.18-P2: cross-process cache (`.docguard/plan.cache.json`). CI flows that
40
- * run guard → sync → fix as separate processes each pay the build cost.
41
- * The disk cache shares the plan across processes, keyed by a tree-state
42
- * hash so we invalidate when the source tree changes.
43
- *
44
- * Cache key: projectDir + a config fingerprint (sourceRoot, ignore,
45
- * projectType, profile). Other config mutations (e.g. changedFiles
46
- * per-validator) don't invalidate the plan.
47
- *
48
- * Bypass with `_skipCache: true` in opts — used by tests.
36
+ * Both cache layers share a working-tree identity (NFR-003). Content, paths,
37
+ * configuration and scanner implementation participate: HEAD/status/mtimes
38
+ * alone cannot distinguish repeated edits to an already-dirty file.
49
39
  */
50
- import { createHash } from 'node:crypto';
51
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from 'node:fs';
52
- import { resolve as resolvePath, join as joinPath } from 'node:path';
53
- import { execFileSync } from 'node:child_process';
54
-
55
- const _memoryPlanCache = new Map(); // key plan
40
+ import { createHash, randomUUID } from 'node:crypto';
41
+ import {
42
+ existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, lstatSync,
43
+ openSync, closeSync, readSync, fstatSync, renameSync, unlinkSync, constants,
44
+ } from 'node:fs';
45
+ import { resolve as resolvePath, join as joinPath, relative, isAbsolute } from 'node:path';
46
+ import { fileURLToPath } from 'node:url';
47
+ import { DEFAULT_IGNORE_DIRS, buildIgnoreFilter } from '../shared-ignore.mjs';
48
+ import { astTierAvailable } from './js-ast.mjs';
49
+ import { pyAstAvailable } from './py-ast.mjs';
50
+
51
+ const _memoryPlanCache = new Map(); // config key → { treeHash, plan }
56
52
  const _DISK_CACHE_PATH = '.docguard/plan.cache.json';
57
- const _DISK_CACHE_VERSION = '1'; // bump if cache shape changes
53
+ const _DISK_CACHE_VERSION = '2';
54
+ const _CACHE_IGNORE_DIRS = new Set([
55
+ ...DEFAULT_IGNORE_DIRS, '.local', '.docguard', '.wolf', '.codex', '.claude',
56
+ ]);
57
+ // A large/unreadable tree is a cache miss, never a partial cache identity.
58
+ const _MAX_HASH_BYTES = 64 * 1024 * 1024;
59
+ const _MAX_HASH_ENTRIES = 50_000;
60
+ const _MAX_CACHE_BYTES = 16 * 1024 * 1024;
61
+ const _MAX_MEMORY_PLANS = 32;
62
+ let _scannerIdentity;
63
+
64
+ function _stableConfig(value) {
65
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
66
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
67
+ if (Array.isArray(value)) return value.map(_stableConfig);
68
+ if (value && Object.getPrototypeOf(value) === Object.prototype) {
69
+ return Object.fromEntries(Object.keys(value).sort()
70
+ .filter(k => value[k] !== undefined).map(k => [k, _stableConfig(value[k])]));
71
+ }
72
+ throw new Error('Non-JSON scanner configuration');
73
+ }
58
74
 
59
- /**
60
- * v0.18-P2: tree-state hash. Cheap signature of the source tree that
61
- * changes whenever something a scanner would care about changes. We use:
62
- * - git HEAD commit SHA (when in a git repo) — captures committed state
63
- * - mtime sum of top-level config files (package.json, pyproject.toml,
64
- * Cargo.toml, etc.) — captures uncommitted bumps to deps
65
- * Combined into a 12-char hex fingerprint.
66
- *
67
- * NOT a perfect cache key — a user editing src/foo.ts without bumping a
68
- * config file won't invalidate. But guard/sync/fix all run in quick
69
- * succession within a CI step, and the user's flow IS bump + commit + run.
70
- * The tradeoff favors speed: the worst case is one stale plan per CI run,
71
- * recoverable with `--no-plan-cache` or a tree change.
72
- */
73
- function _treeStateHash(projectDir) {
74
- let signal = '';
75
- // git HEAD
75
+ function _cacheKey(projectDir, config) {
76
76
  try {
77
- const sha = execFileSync('git', ['rev-parse', 'HEAD'], {
78
- cwd: projectDir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
79
- }).trim();
80
- signal += `git:${sha};`;
81
- } catch { /* not a git repo, or no commits */ }
82
- // mtime of common manifest files
83
- const manifests = [
84
- 'package.json', 'pyproject.toml', 'Cargo.toml', 'go.mod',
85
- 'pom.xml', 'build.gradle', 'Gemfile', 'composer.json',
86
- '.docguard.json',
87
- ];
88
- for (const m of manifests) {
89
- try {
90
- const s = statSync(resolvePath(projectDir, m));
91
- signal += `${m}:${s.mtimeMs};`;
92
- } catch { /* not present */ }
93
- }
94
- return createHash('sha256').update(signal).digest('hex').slice(0, 12);
77
+ if (_scannerIdentity === undefined) {
78
+ // Once per process: also invalidate developer builds without a version
79
+ // bump. Loaded ESM modules themselves are immutable within this process.
80
+ const cli = fileURLToPath(new URL('../', import.meta.url));
81
+ const hash = createHash('sha256');
82
+ hash.update(readFileSync(new URL('../../package.json', import.meta.url)));
83
+ for (const dir of [cli, joinPath(cli, 'scanners')]) {
84
+ for (const name of readdirSync(dir).sort()) {
85
+ if (name.endsWith('.mjs')) hash.update(name).update(readFileSync(joinPath(dir, name)));
86
+ }
87
+ }
88
+ hash.update(JSON.stringify([process.version, astTierAvailable(), pyAstAvailable()]));
89
+ _scannerIdentity = hash.digest('hex');
90
+ }
91
+ // changedFiles scopes validators, not plan scanners; diskCache is policy.
92
+ const { changedFiles, diskCache, ...scannerConfig } = config;
93
+ return createHash('sha256').update(JSON.stringify([
94
+ projectDir, _scannerIdentity, _stableConfig(scannerConfig),
95
+ ])).digest('hex');
96
+ } catch { return null; }
95
97
  }
96
98
 
97
- /**
98
- * v0.18-P2: read the disk cache. Returns null when the file is missing,
99
- * the schema version mismatches, the tree hash doesn't match, or anything
100
- * about the load is suspicious. Never throws — cache miss is silent.
101
- */
102
- function _readDiskCache(projectDir, configKey) {
99
+ function _treeStateHash(projectDir, config) {
103
100
  try {
104
- const p = resolvePath(projectDir, _DISK_CACHE_PATH);
105
- if (!existsSync(p)) return null;
106
- const data = JSON.parse(readFileSync(p, 'utf-8'));
107
- if (data.v !== _DISK_CACHE_VERSION) return null;
108
- if (data.configKey !== configKey) return null;
109
- const currentHash = _treeStateHash(projectDir);
110
- if (data.treeHash !== currentHash) return null;
111
- return data.plan || null;
112
- } catch {
113
- return null;
101
+ // Source roots outside this tree cannot be certified by a project walk.
102
+ const ignored = buildIgnoreFilter(config.ignore || []);
103
+ const covered = root => {
104
+ const rel = relative(projectDir, resolvePath(projectDir, root)).replaceAll('\\', '/');
105
+ return !isAbsolute(rel) && rel !== '..' && !rel.startsWith('../')
106
+ && !rel.split('/').some(part => _CACHE_IGNORE_DIRS.has(part)) && !ignored(rel);
107
+ };
108
+ const roots = Array.isArray(config.sourceRoot) ? config.sourceRoot : [config.sourceRoot];
109
+ for (const root of roots.filter(Boolean)) {
110
+ if (!covered(root)) return null;
111
+ }
112
+ const hash = createHash('sha256');
113
+ const buffer = Buffer.allocUnsafe(64 * 1024);
114
+ let bytes = 0;
115
+ let entries = 0;
116
+ function walk(dir, prefix = '') {
117
+ for (const name of readdirSync(dir).sort()) {
118
+ if (_CACHE_IGNORE_DIRS.has(name)) continue;
119
+ const rel = prefix ? `${prefix}/${name}` : name;
120
+ // Always fingerprint the rules themselves, even if they exclude self.
121
+ if (name !== '.docguardignore' && name !== '.docguard.json' && ignored(rel)) continue;
122
+ if (++entries > _MAX_HASH_ENTRIES) throw new Error('Cache tree too large');
123
+ const path = joinPath(dir, name);
124
+ const stat = lstatSync(path);
125
+ // Do not follow links (including broken links/cycles/private targets).
126
+ // Some scanners follow known input names, so skipping a link is not a
127
+ // complete identity either: bypass caching for the whole build.
128
+ if (stat.isSymbolicLink()) throw new Error('Linked input');
129
+ hash.update(JSON.stringify([rel, stat.isDirectory() ? 'dir' : 'file']));
130
+ if (stat.isDirectory()) { walk(path, rel); continue; }
131
+ if (!stat.isFile()) throw new Error('Non-regular input');
132
+ // SECURITY.md: .env values must never be read. Metadata still tracks
133
+ // existence/replacement/edits without incorporating secret contents.
134
+ if (/^\.env(?:\.|$)/.test(name)) {
135
+ hash.update(JSON.stringify([stat.size, stat.mtimeMs, stat.ctimeMs, stat.ino]));
136
+ continue;
137
+ }
138
+ bytes += stat.size;
139
+ if (bytes > _MAX_HASH_BYTES) throw new Error('Cache tree too large');
140
+ const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
141
+ try {
142
+ const before = fstatSync(fd);
143
+ if (!before.isFile() || before.ino !== stat.ino || before.dev !== stat.dev)
144
+ throw new Error('Input replaced while hashing');
145
+ const content = createHash('sha256');
146
+ let count = 0;
147
+ let n;
148
+ while ((n = readSync(fd, buffer, 0, buffer.length, null)) > 0) {
149
+ count += n;
150
+ if (count > stat.size) throw new Error('Input grew while hashing');
151
+ content.update(buffer.subarray(0, n));
152
+ }
153
+ const after = fstatSync(fd);
154
+ if (count !== stat.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs)
155
+ throw new Error('Input changed while hashing');
156
+ hash.update(content.digest('hex'));
157
+ } finally { closeSync(fd); }
158
+ }
159
+ }
160
+ walk(projectDir);
161
+ // Inspect declarations before expanding workspace globs: the shared
162
+ // resolver can otherwise traverse an external/private workspace just to
163
+ // discover its package names. This check only reads root manifests that
164
+ // the completed walk already certified as regular files.
165
+ for (const name of ['package.json', 'pnpm-workspace.yaml']) {
166
+ const path = joinPath(projectDir, name);
167
+ if (!existsSync(path)) continue;
168
+ if (ignored(name) || !lstatSync(path).isFile()) return null;
169
+ const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
170
+ let content;
171
+ try { content = readFileSync(fd, 'utf-8'); }
172
+ finally { closeSync(fd); }
173
+ const declarations = name === 'package.json'
174
+ ? (() => {
175
+ const workspaces = JSON.parse(content).workspaces;
176
+ return Array.isArray(workspaces) ? workspaces : workspaces?.packages || [];
177
+ })()
178
+ : [...content.matchAll(/^\s*-\s*['"]?([^'"\n]+?)['"]?\s*$/gm)].map(m => m[1]);
179
+ if (declarations.some(root => !covered(root))) return null;
180
+ }
181
+ return hash.digest('hex');
182
+ } catch { return null; }
183
+ }
184
+
185
+ function _cacheDirectorySafe(projectDir) {
186
+ try { return lstatSync(joinPath(projectDir, '.docguard')).isDirectory(); }
187
+ catch { return false; }
188
+ }
189
+
190
+ /** Validate the serialized contract before any consumer can dereference it. */
191
+ function _validCachedPlan(plan) {
192
+ const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
193
+ const text = value => typeof value === 'string';
194
+ const nullableText = value => value == null || text(value);
195
+ const count = value => Number.isSafeInteger(value) && value >= 0;
196
+ const list = (value, valid) => Array.isArray(value) && value.every(valid);
197
+ const texts = value => list(value, text);
198
+ const records = value => list(value, record);
199
+ // Consumers serialize grounding and other scanner metadata. Reject deep or
200
+ // oversized object graphs too, so valid JSON cannot cause a stack overflow.
201
+ let nodes = 0;
202
+ const json = (value, depth = 0) => {
203
+ if (++nodes > 200_000 || depth > 64) return false;
204
+ if (value === null || text(value) || typeof value === 'boolean') return true;
205
+ if (typeof value === 'number') return Number.isFinite(value);
206
+ if (!record(value) && !Array.isArray(value)) return false;
207
+ return Object.values(value).every(child => json(child, depth + 1));
208
+ };
209
+ const ecosystem = value => record(value) && text(value.dir) && text(value.language)
210
+ && text(value.kind) && nullableText(value.framework);
211
+ const profile = value => record(value) && text(value.kind) && typeof value.polyglot === 'boolean'
212
+ && texts(value.languages) && texts(value.frameworks) && list(value.ecosystems, ecosystem)
213
+ && (value.primary === null || ecosystem(value.primary));
214
+ const docPath = value => text(value) && /^docs-(?:canonical|implementation)\/.+\.md$/.test(value)
215
+ && !/[\\:\x00-\x1f]/.test(value) && value.split('/').every(part => part && !part.startsWith('.'));
216
+ const grounding = value => value == null || record(value);
217
+ const section = value => record(value) && text(value.id) && (
218
+ value.source === 'code' ? text(value.body)
219
+ : value.source === 'human' && text(value.task) && grounding(value.grounding)
220
+ );
221
+ const namedFile = value => record(value) && text(value.name) && text(value.file);
222
+ if (!record(plan) || !json(plan) || !profile(plan.profile) || !texts(plan.notes)
223
+ || !list(plan.docs, doc => record(doc) && docPath(doc.path) && list(doc.sections, section))
224
+ || !list(plan.agentTasks, task => record(task) && docPath(task.doc) && text(task.sectionId)
225
+ && text(task.instruction) && grounding(task.grounding))) return false;
226
+ const s = plan.surface;
227
+ return record(s) && profile(s.profile)
228
+ && list(s.endpoints, e => record(e) && text(e.method) && text(e.path) && typeof e.auth === 'boolean')
229
+ && list(s.entities, e => record(e) && text(e.name) && records(e.fields))
230
+ && list(s.screens, e => record(e) && text(e.path) && text(e.file) && nullableText(e.component))
231
+ && [s.components, s.stores, s.hooks, s.contexts].every(items => list(items, namedFile))
232
+ && list(s.apiCalls, e => record(e) && text(e.method) && text(e.path))
233
+ && texts(s.envVars)
234
+ && list(s.integrations, e => record(e) && text(e.name) && text(e.category) && texts(e.evidence))
235
+ && list(s.modules, e => record(e) && text(e.name) && text(e.path) && ['module', 'file'].includes(e.kind))
236
+ && record(s.tests) && count(s.tests.totalFiles) && count(s.tests.totalCases)
237
+ && list(s.tests.files, e => record(e) && text(e.file) && count(e.cases))
238
+ && record(s.i18n) && texts(s.i18n.usedKeys) && texts(s.i18n.missing)
239
+ && list(s.i18n.locales, e => record(e) && text(e.file) && count(e.keys))
240
+ && record(s.frontend) && [s.frontend.framework, s.frontend.stateLib, s.frontend.dataLib].every(nullableText);
241
+ }
242
+
243
+ /** One insertion policy for fresh builds and disk promotions (FIFO, not LRU). */
244
+ function _rememberPlan(key, treeHash, plan) {
245
+ _memoryPlanCache.delete(key);
246
+ while (_memoryPlanCache.size >= _MAX_MEMORY_PLANS) {
247
+ _memoryPlanCache.delete(_memoryPlanCache.keys().next().value);
114
248
  }
249
+ _memoryPlanCache.set(key, { treeHash, plan });
115
250
  }
116
251
 
117
- /**
118
- * v0.18-P2: write the disk cache. Best-effort — failures are silent (the
119
- * in-process cache still works). `.docguard/` directory created if needed.
120
- */
121
- function _writeDiskCache(projectDir, configKey, plan) {
252
+ function _readDiskCache(projectDir, configKey, treeHash) {
122
253
  try {
123
- const fullDir = resolvePath(projectDir, '.docguard');
124
- if (!existsSync(fullDir)) mkdirSync(fullDir, { recursive: true });
125
- const payload = {
126
- v: _DISK_CACHE_VERSION,
127
- configKey,
128
- treeHash: _treeStateHash(projectDir),
129
- plan,
130
- writtenAt: new Date().toISOString(),
131
- };
132
- writeFileSync(
133
- resolvePath(projectDir, _DISK_CACHE_PATH),
134
- JSON.stringify(payload), // compact — this file isn't human-edited
135
- 'utf-8'
136
- );
137
- } catch {
138
- // swallow — the cache is auxiliary
254
+ if (!_cacheDirectorySafe(projectDir)) return null;
255
+ const path = resolvePath(projectDir, _DISK_CACHE_PATH);
256
+ const stat = lstatSync(path);
257
+ if (!stat.isFile() || stat.size > _MAX_CACHE_BYTES) return null;
258
+ const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
259
+ let data;
260
+ try { data = JSON.parse(readFileSync(fd, 'utf-8')); }
261
+ finally { closeSync(fd); }
262
+ if (data.v !== _DISK_CACHE_VERSION || data.configKey !== configKey || data.treeHash !== treeHash) return null;
263
+ return _validCachedPlan(data.plan) ? data.plan : null;
264
+ } catch { return null; }
265
+ }
266
+
267
+ function _writeDiskCache(projectDir, configKey, treeHash, plan) {
268
+ let temporary;
269
+ try {
270
+ const dir = joinPath(projectDir, '.docguard');
271
+ if (!existsSync(dir)) mkdirSync(dir);
272
+ if (!_cacheDirectorySafe(projectDir)) return;
273
+ const path = resolvePath(projectDir, _DISK_CACHE_PATH);
274
+ // Never read or overwrite the target of a pre-existing cache symlink.
275
+ try { if (!lstatSync(path).isFile()) return; }
276
+ catch (err) { if (err.code !== 'ENOENT') return; }
277
+ const payload = JSON.stringify({
278
+ v: _DISK_CACHE_VERSION, configKey, treeHash, plan, writtenAt: new Date().toISOString(),
279
+ });
280
+ if (Buffer.byteLength(payload) > _MAX_CACHE_BYTES) return;
281
+ temporary = joinPath(dir, `plan.cache.${randomUUID()}.tmp`);
282
+ // safeWrite is for generated docs (backup + overwrite). Cache publication
283
+ // needs an exclusive temporary file + atomic rename so competing readers
284
+ // never see partial JSON; backups would only preserve disposable data.
285
+ writeFileSync(temporary, payload, { encoding: 'utf-8', flag: 'wx', mode: 0o600 });
286
+ if (!_cacheDirectorySafe(projectDir)) return;
287
+ renameSync(temporary, path);
288
+ } catch { /* cache persistence is best-effort */ }
289
+ finally {
290
+ if (temporary && _cacheDirectorySafe(projectDir)) {
291
+ try { unlinkSync(temporary); } catch { /* renamed or unavailable */ }
292
+ }
139
293
  }
140
294
  }
141
295
 
@@ -143,52 +297,41 @@ export function clearMemoryPlanCache() {
143
297
  _memoryPlanCache.clear();
144
298
  }
145
299
 
146
- function _cacheKey(projectDir, config) {
147
- return JSON.stringify({
148
- dir: projectDir,
149
- sourceRoot: config.sourceRoot,
150
- ignore: Array.isArray(config.ignore) ? [...config.ignore].sort() : null,
151
- projectType: config.projectType,
152
- profile: config.profile,
153
- });
154
- }
155
-
156
- /**
157
- * Build the full memory plan for a project.
158
- * @returns {{ profile, surface, docs, agentTasks }}
159
- * docs[].sections[]: { id, source:'code', body } OR { id, source:'human', task, grounding }
160
- * agentTasks: flattened prose tasks the AI must write.
161
- */
300
+ /** Build the plan, caching only a complete, stable working-tree snapshot. */
162
301
  export function buildMemoryPlan(projectDir, config = {}, opts = {}) {
163
- const useCache = !opts._skipCache;
164
- const key = useCache ? _cacheKey(projectDir, config) : null;
165
-
166
- // L1: in-process Map (same-run guard → sync → fix).
167
- if (useCache) {
302
+ projectDir = resolvePath(projectDir);
303
+ // Read current ignore rules on every call, including calls with reused config.
304
+ // Refuse symlinked ignore files rather than following them in the cache layer.
305
+ let cacheable = !opts._skipCache;
306
+ try {
307
+ const path = joinPath(projectDir, '.docguardignore');
308
+ if (lstatSync(path).isFile()) {
309
+ const patterns = readFileSync(path, 'utf-8').split(/\r?\n/)
310
+ .map(line => line.trim()).filter(line => line && !line.startsWith('#'));
311
+ config = { ...config, ignore: [...new Set([...(config.ignore || []), ...patterns])] };
312
+ } else { cacheable = false; }
313
+ } catch (err) { if (err.code !== 'ENOENT') cacheable = false; }
314
+ const key = cacheable ? _cacheKey(projectDir, config) : null;
315
+ const treeHash = key ? _treeStateHash(projectDir, config) : null;
316
+ if (treeHash) {
168
317
  const cached = _memoryPlanCache.get(key);
169
- if (cached) return cached;
170
- }
171
-
172
- // L2: cross-process disk cache (CI guard → CI sync → CI fix).
173
- // v0.18-P2: opt-in via config.diskCache !== false (default ON).
174
- // Tree-state hash invalidates when source files change.
175
- const diskCacheEnabled = useCache && config.diskCache !== false;
176
- if (diskCacheEnabled) {
177
- const onDisk = _readDiskCache(projectDir, key);
178
- if (onDisk) {
179
- _memoryPlanCache.set(key, onDisk); // promote to L1
180
- return onDisk;
318
+ if (cached?.treeHash === treeHash) return cached.plan;
319
+ _memoryPlanCache.delete(key);
320
+ if (config.diskCache !== false) {
321
+ const onDisk = _readDiskCache(projectDir, key, treeHash);
322
+ if (onDisk) {
323
+ _rememberPlan(key, treeHash, onDisk);
324
+ return onDisk;
325
+ }
181
326
  }
182
- }
327
+ } else if (key) { _memoryPlanCache.delete(key); }
183
328
 
184
- // Miss — build fresh.
185
329
  const result = _buildMemoryPlanUncached(projectDir, config);
186
-
187
- if (useCache) {
188
- _memoryPlanCache.set(key, result);
189
- }
190
- if (diskCacheEnabled) {
191
- _writeDiskCache(projectDir, key, result);
330
+ // Scanners perform several walks. Do not stamp a mixed-state result with a
331
+ // later tree's identity if an editor changed inputs during the build.
332
+ if (treeHash && _treeStateHash(projectDir, config) === treeHash) {
333
+ _rememberPlan(key, treeHash, result);
334
+ if (config.diskCache !== false) _writeDiskCache(projectDir, key, treeHash, result);
192
335
  }
193
336
  return result;
194
337
  }
@@ -465,5 +608,7 @@ function _buildMemoryPlanUncached(projectDir, config = {}) {
465
608
  ],
466
609
  });
467
610
 
611
+ for (const doc of docs) doc.path = remapDocPath(config, doc.path);
612
+ for (const task of agentTasks) task.doc = remapDocPath(config, task.doc);
468
613
  return { profile, surface, docs, agentTasks, notes };
469
614
  }
@@ -16,6 +16,7 @@
16
16
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
17
17
  import { resolve, join, relative, dirname, basename } from 'node:path';
18
18
  import { shouldIgnore, relPosix, isNonProductDir } from '../shared-ignore.mjs';
19
+ import { hasWorkerConfig } from '../shared-source.mjs';
19
20
 
20
21
  const IGNORE_DIRS = new Set([
21
22
  'node_modules', '.git', '.next', 'dist', 'build', 'coverage', 'target',
@@ -26,6 +27,9 @@ const IGNORE_DIRS = new Set([
26
27
  // Manifest filename → ecosystem language.
27
28
  const MANIFESTS = [
28
29
  { file: 'package.json', lang: 'JavaScript' },
30
+ { file: 'wrangler.toml', lang: 'JavaScript' },
31
+ { file: 'wrangler.json', lang: 'JavaScript' },
32
+ { file: 'wrangler.jsonc', lang: 'JavaScript' },
29
33
  { file: 'pyproject.toml', lang: 'Python' },
30
34
  { file: 'requirements.txt', lang: 'Python' },
31
35
  { file: 'setup.py', lang: 'Python' },
@@ -169,7 +173,8 @@ function classify(lang, dir, deps) {
169
173
  let kind = 'library';
170
174
 
171
175
  if (lang === 'JavaScript' || lang === 'TypeScript') {
172
- if (has(deps, 'next')) { framework = 'Next.js'; kind = 'webapp'; }
176
+ if (hasWorkerConfig(dir)) { framework = 'Cloudflare Workers'; kind = 'service'; }
177
+ else if (has(deps, 'next')) { framework = 'Next.js'; kind = 'webapp'; }
173
178
  else if (has(deps, 'react', 'vue', '@angular/core', 'svelte', '@sveltejs/kit', 'nuxt')) { framework = has(deps,'react')?'React':has(deps,'vue')?'Vue':'Frontend'; kind = 'webapp'; }
174
179
  else if (has(deps, 'express', 'fastify', 'hono', 'koa', '@nestjs/core')) { framework = has(deps,'express')?'Express':has(deps,'fastify')?'Fastify':has(deps,'@nestjs/core')?'NestJS':'Hono'; kind = 'api'; }
175
180
  } else if (lang === 'Python') {