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.
- package/README.md +8 -15
- package/cli/commands/agent.mjs +27 -6
- package/cli/commands/ci.mjs +3 -0
- package/cli/commands/diagnose.mjs +8 -2
- package/cli/commands/feedback.mjs +83 -89
- package/cli/commands/fix.mjs +4 -0
- package/cli/commands/generate.mjs +3 -0
- package/cli/commands/guard.mjs +37 -20
- package/cli/commands/hooks.mjs +61 -40
- package/cli/commands/init.mjs +51 -5
- package/cli/commands/memory.mjs +29 -15
- package/cli/commands/report.mjs +12 -7
- package/cli/commands/score.mjs +39 -19
- package/cli/commands/sync.mjs +2 -0
- package/cli/commands/watch.mjs +113 -70
- package/cli/config.mjs +6 -3
- package/cli/docguard.mjs +12 -4
- package/cli/findings.mjs +13 -13
- package/cli/scanners/memory-plan.mjs +279 -134
- package/cli/scanners/project-type.mjs +6 -1
- package/cli/scanners/semantic-claims.mjs +176 -26
- package/cli/shared-diff.mjs +22 -1
- package/cli/shared-doc-roles.mjs +59 -0
- package/cli/shared-ignore.mjs +15 -2
- package/cli/shared-source.mjs +223 -1
- package/cli/validator-coverage.mjs +20 -0
- package/cli/validators/api-surface.mjs +94 -70
- package/cli/validators/architecture.mjs +19 -5
- package/cli/validators/diff-suspicion.mjs +45 -9
- package/cli/validators/docs-coverage.mjs +6 -5
- package/cli/validators/docs-diff.mjs +51 -7
- package/cli/validators/environment.mjs +3 -2
- package/cli/validators/freshness.mjs +140 -83
- package/cli/validators/schema-sync.mjs +3 -2
- package/cli/validators/security.mjs +58 -23
- package/cli/validators/structure.mjs +3 -1
- package/cli/validators/test-spec.mjs +3 -2
- package/cli/validators/todo-tracking.mjs +61 -28
- package/cli/validators/traceability.mjs +152 -38
- package/docs/configuration.md +41 -0
- package/extensions/spec-kit-docguard/README.md +6 -6
- package/extensions/spec-kit-docguard/commands/sync.md +1 -1
- package/extensions/spec-kit-docguard/extension.yml +3 -4
- package/extensions/spec-kit-docguard/scripts/bash/common.sh +9 -17
- package/extensions/spec-kit-docguard/scripts/bash/docguard-check-docs.sh +18 -11
- package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +3 -3
- package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/templates/extensions.yml +1 -2
- package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +74 -29
- package/package.json +1 -1
- package/schemas/docguard-config.schema.json +43 -1
- 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
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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 {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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 = '
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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 (
|
|
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') {
|