docguard-cli 0.38.0 → 0.40.0

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 (63) hide show
  1. package/README.md +45 -22
  2. package/cli/commands/agent.mjs +47 -1
  3. package/cli/commands/explain.mjs +16 -0
  4. package/cli/commands/feedback.mjs +147 -6
  5. package/cli/commands/fix.mjs +13 -11
  6. package/cli/commands/generate.mjs +52 -18
  7. package/cli/commands/guard.mjs +13 -2
  8. package/cli/commands/mcp.mjs +22 -2
  9. package/cli/commands/score.mjs +13 -1
  10. package/cli/commands/specs.mjs +21 -2
  11. package/cli/commands/sync.mjs +20 -7
  12. package/cli/commands/verify.mjs +65 -2
  13. package/cli/config.mjs +3 -0
  14. package/cli/docguard.mjs +48 -16
  15. package/cli/evidence/adapters.mjs +200 -0
  16. package/cli/evidence/evaluate.mjs +185 -0
  17. package/cli/evidence/manifest.mjs +194 -0
  18. package/cli/evidence/markdown.mjs +107 -0
  19. package/cli/feedback-fixture.mjs +188 -0
  20. package/cli/findings.mjs +31 -0
  21. package/cli/repository-root.mjs +159 -0
  22. package/cli/scanners/py-ast.mjs +39 -2
  23. package/cli/scanners/task-context.mjs +312 -0
  24. package/cli/shared-doc-roles.mjs +44 -1
  25. package/cli/shared-source.mjs +101 -28
  26. package/cli/validators/architecture.mjs +186 -13
  27. package/cli/validators/environment.mjs +14 -1
  28. package/cli/validators/evidence.mjs +52 -0
  29. package/cli/validators/security.mjs +5 -4
  30. package/cli/validators/todo-tracking.mjs +45 -2
  31. package/cli/writers/doc-generators.mjs +31 -17
  32. package/cli/writers/mechanical.mjs +44 -14
  33. package/cli/writers/sections.mjs +31 -3
  34. package/docs/ai-integration.md +31 -6
  35. package/docs/commands.md +43 -5
  36. package/docs/configuration.md +11 -3
  37. package/docs/quickstart.md +1 -1
  38. package/extensions/spec-kit-docguard/commands/fix.md +4 -2
  39. package/extensions/spec-kit-docguard/commands/generate.md +6 -1
  40. package/extensions/spec-kit-docguard/commands/guard.md +3 -2
  41. package/extensions/spec-kit-docguard/commands/sync.md +1 -1
  42. package/extensions/spec-kit-docguard/extension.yml +1 -1
  43. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +14 -3
  44. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +16 -5
  45. package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +8 -3
  46. package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +3 -2
  47. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +6 -3
  48. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-autofix.yml +2 -2
  49. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +4 -4
  50. package/package.json +3 -1
  51. package/schemas/docguard-agent-context-benchmark.schema.json +92 -0
  52. package/schemas/docguard-agent-context-result.schema.json +95 -0
  53. package/schemas/docguard-benchmark.schema.json +84 -0
  54. package/schemas/docguard-config.schema.json +1 -0
  55. package/schemas/docguard-evidence.schema.json +169 -0
  56. package/schemas/docguard-feedback-fixture.schema.json +54 -0
  57. package/schemas/docguard-task-context.schema.json +144 -0
  58. package/templates/AGENTS.md.template +9 -4
  59. package/templates/ci/github-actions.yml +4 -4
  60. package/templates/commands/docguard.guard.md +5 -1
  61. package/templates/commands/docguard.review.md +6 -1
  62. package/templates/evidence-manifest.json +21 -0
  63. package/templates/feedback-fixture.json +18 -0
@@ -0,0 +1,159 @@
1
+ /** Read-only repository-root guidance for commands launched in nested packages. */
2
+ import { execFileSync } from 'node:child_process';
3
+ import { existsSync, lstatSync, readFileSync } from 'node:fs';
4
+ import { dirname, isAbsolute, join, posix, relative, resolve } from 'node:path';
5
+ import { compileGlob } from './shared-ignore.mjs';
6
+
7
+ const MAX_ANCESTORS = 32;
8
+ const MAX_MANIFEST_BYTES = 256 * 1024;
9
+
10
+ function regularFile(path) {
11
+ try {
12
+ const stat = lstatSync(path);
13
+ return stat.isFile() && !stat.isSymbolicLink() && stat.size <= MAX_MANIFEST_BYTES;
14
+ } catch { return false; }
15
+ }
16
+
17
+ function readRegular(path) {
18
+ if (!regularFile(path)) return null;
19
+ try { return readFileSync(path, 'utf8'); } catch { return null; }
20
+ }
21
+
22
+ function gitTopLevel(dir) {
23
+ try {
24
+ return resolve(execFileSync('git', ['-C', dir, 'rev-parse', '--show-toplevel'], {
25
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
26
+ env: { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0' },
27
+ timeout: 3000,
28
+ }).trim());
29
+ } catch { return null; }
30
+ }
31
+
32
+ function within(parent, child) {
33
+ const rel = relative(parent, child);
34
+ return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`));
35
+ }
36
+
37
+ function normalizePattern(pattern) {
38
+ const value = String(pattern).trim().replaceAll('\\', '/').replace(/^\.\//, '');
39
+ return posix.normalize(value).replace(/^\.\//, '').replace(/\/$/, '');
40
+ }
41
+
42
+ function npmPatterns(root) {
43
+ const content = readRegular(join(root, 'package.json'));
44
+ if (content == null) return [];
45
+ try {
46
+ const value = JSON.parse(content).workspaces;
47
+ const patterns = Array.isArray(value) ? value : value?.packages;
48
+ return Array.isArray(patterns) ? patterns.filter(item => typeof item === 'string') : [];
49
+ } catch { return []; }
50
+ }
51
+
52
+ function pnpmPatterns(root) {
53
+ const content = readRegular(join(root, 'pnpm-workspace.yaml'));
54
+ if (content == null) return [];
55
+ const patterns = [];
56
+ let packages = false;
57
+ for (const line of content.split(/\r?\n/)) {
58
+ if (/^packages\s*:/.test(line)) { packages = true; continue; }
59
+ if (!packages) continue;
60
+ if (/^[^\s#][^:]*\s*:/.test(line)) break;
61
+ const match = line.match(/^\s*-\s*(?:'([^']*)'|"([^"]*)"|([^#]*?))\s*(?:#.*)?$/);
62
+ const value = match && (match[1] ?? match[2] ?? match[3])?.trim();
63
+ if (value) patterns.push(value);
64
+ }
65
+ return patterns;
66
+ }
67
+
68
+ function matchesWorkspace(patterns, packagePath) {
69
+ const normalized = patterns.map(normalizePattern).filter(Boolean);
70
+ const positives = normalized.filter(pattern => !pattern.startsWith('!'));
71
+ const negatives = normalized.filter(pattern => pattern.startsWith('!')).map(pattern => pattern.slice(1));
72
+ const matches = (pattern) => {
73
+ try { return compileGlob(pattern).test(packagePath); } catch { return false; }
74
+ };
75
+ return positives.some(matches) && !negatives.some(matches);
76
+ }
77
+
78
+ function workspaceOwner(root, selected) {
79
+ const hasPnpmWorkspace = regularFile(join(root, 'pnpm-workspace.yaml'));
80
+ const pnpm = pnpmPatterns(root);
81
+ const npm = hasPnpmWorkspace ? [] : npmPatterns(root);
82
+ const patterns = hasPnpmWorkspace ? pnpm : npm;
83
+ if (patterns.length === 0) return null;
84
+
85
+ let packageDir = selected;
86
+ while (within(root, packageDir) && packageDir !== root) {
87
+ if (regularFile(join(packageDir, 'package.json'))) {
88
+ const packagePath = relative(root, packageDir).replaceAll('\\', '/');
89
+ if (matchesWorkspace(patterns, packagePath)) {
90
+ return {
91
+ reason: hasPnpmWorkspace ? 'pnpm_workspace' : 'npm_workspace',
92
+ evidence: hasPnpmWorkspace ? 'pnpm-workspace.yaml#packages' : 'package.json#workspaces',
93
+ packagePath,
94
+ };
95
+ }
96
+ }
97
+ const parent = dirname(packageDir);
98
+ if (parent === packageDir) break;
99
+ packageDir = parent;
100
+ }
101
+ return null;
102
+ }
103
+
104
+ function shellQuote(value) {
105
+ const text = String(value);
106
+ if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(text)) return text;
107
+ return `'${text.replaceAll("'", `'"'"'`)}'`;
108
+ }
109
+
110
+ /**
111
+ * Find the nearest ancestor with explicit DocGuard or workspace ownership.
112
+ * The selected directory is never changed.
113
+ * @implements docguard.language-repository-coverage#FR-012
114
+ * @implements docguard.language-repository-coverage#FR-013
115
+ * @implements docguard.language-repository-coverage#FR-014
116
+ */
117
+ export function detectRepositoryRootGuidance(selectedDir, { explicitDir = false, argv = [] } = {}) {
118
+ const selected = resolve(selectedDir);
119
+ if (explicitDir || existsSync(join(selected, '.docguard.json'))) return null;
120
+ const gitRoot = gitTopLevel(selected);
121
+ let current = dirname(selected);
122
+ for (let depth = 1; depth <= MAX_ANCESTORS && current !== dirname(current); depth++) {
123
+ // A nested repository is an independent boundary unless ownership is
124
+ // declared inside that same working tree.
125
+ if (gitRoot && !within(gitRoot, current)) break;
126
+ let ownership = regularFile(join(current, '.docguard.json'))
127
+ ? { reason: 'ancestor_docguard_config', evidence: '.docguard.json', packagePath: relative(current, selected).replaceAll('\\', '/') }
128
+ : null;
129
+ ownership ||= workspaceOwner(current, selected);
130
+ if (ownership) {
131
+ const rerun = ['docguard', ...argv.map(shellQuote), '--dir', shellQuote(current)].join(' ');
132
+ return {
133
+ selectedDir: selected,
134
+ suggestedDir: current,
135
+ reason: ownership.reason,
136
+ evidence: ownership.evidence,
137
+ packagePath: ownership.packagePath,
138
+ gitRoot,
139
+ rerun,
140
+ automaticScopeChange: false,
141
+ };
142
+ }
143
+ current = dirname(current);
144
+ }
145
+ return null;
146
+ }
147
+
148
+ export function renderRepositoryRootGuidance(guidance, { machine = false } = {}) {
149
+ if (!guidance) return '';
150
+ if (machine) return JSON.stringify({
151
+ type: 'docguard.repository-root-guidance',
152
+ repositoryRootGuidance: guidance,
153
+ });
154
+ return [
155
+ `DocGuard is checking ${guidance.selectedDir}.`,
156
+ `${guidance.suggestedDir} appears to govern this package via ${guidance.evidence}; the scan scope was not changed.`,
157
+ `Re-run for repository scope: ${guidance.rerun}`,
158
+ ].join('\n');
159
+ }
@@ -13,6 +13,8 @@
13
13
  * (or `python`) isn't on PATH, or the subprocess errors, every entry point here
14
14
  * returns `null` and the callers transparently fall back to their regex (beta)
15
15
  * tier. Python parsing never becomes load-bearing for the CLI to run.
16
+ * @implements docguard.language-repository-coverage#FR-001
17
+ * @implements docguard.language-repository-coverage#FR-004
16
18
  */
17
19
  import { spawnSync } from 'node:child_process';
18
20
 
@@ -147,6 +149,37 @@ def fields_from_class(cls):
147
149
  rels.append(rel)
148
150
  return pyd, orm, rels
149
151
 
152
+ def imports_from_tree(tree):
153
+ imports = []
154
+ dynamic = False
155
+ path_mutation = False
156
+ for node in ast.walk(tree):
157
+ if isinstance(node, ast.Import):
158
+ for name in node.names:
159
+ imports.append({"kind": "import", "module": name.name, "level": 0, "names": []})
160
+ elif isinstance(node, ast.ImportFrom):
161
+ imports.append({
162
+ "kind": "from", "module": node.module or "", "level": node.level or 0,
163
+ "names": [name.name for name in node.names]
164
+ })
165
+ elif isinstance(node, ast.Call):
166
+ fn = node.func
167
+ if isinstance(fn, ast.Name) and fn.id == "__import__":
168
+ dynamic = True
169
+ elif isinstance(fn, ast.Attribute):
170
+ if isinstance(fn.value, ast.Name) and fn.value.id == "importlib" and fn.attr == "import_module":
171
+ dynamic = True
172
+ if fn.attr in {"append", "insert", "extend"} and isinstance(fn.value, ast.Attribute):
173
+ if isinstance(fn.value.value, ast.Name) and fn.value.value.id == "sys" and fn.value.attr == "path":
174
+ path_mutation = True
175
+ elif isinstance(node, (ast.Assign, ast.AugAssign)):
176
+ targets = node.targets if isinstance(node, ast.Assign) else [node.target]
177
+ for target in targets:
178
+ if isinstance(target, ast.Attribute) and isinstance(target.value, ast.Name):
179
+ if target.value.id == "sys" and target.attr == "path":
180
+ path_mutation = True
181
+ return imports, dynamic, path_mutation
182
+
150
183
  results = []
151
184
  for path in sys.stdin.read().splitlines():
152
185
  path = path.strip()
@@ -169,7 +202,11 @@ for path in sys.stdin.read().splitlines():
169
202
  schemas.append({"name": node.name, "fields": pyd, "kind": "pydantic", "rels": rels})
170
203
  elif any(b in ORM_BASES for b in bn) and orm:
171
204
  schemas.append({"name": node.name, "fields": orm, "kind": "sqlalchemy", "rels": rels})
172
- results.append({"file": path, "ok": True, "routes": routes, "schemas": schemas})
205
+ imports, dynamic_imports, path_mutation = imports_from_tree(tree)
206
+ results.append({
207
+ "file": path, "ok": True, "routes": routes, "schemas": schemas,
208
+ "imports": imports, "dynamicImports": dynamic_imports, "pathMutation": path_mutation
209
+ })
173
210
 
174
211
  sys.stdout.write(json.dumps(results))
175
212
  `;
@@ -178,7 +215,7 @@ sys.stdout.write(json.dumps(results))
178
215
  * Parse a batch of Python files in ONE python3 subprocess.
179
216
  *
180
217
  * @param {string[]} filePaths - absolute paths to .py files
181
- * @returns {Object<string, {ok:boolean, routes?, schemas?}>|null}
218
+ * @returns {Object<string, {ok:boolean, routes?, schemas?, imports?, dynamicImports?, pathMutation?}>|null}
182
219
  * A map keyed by the input path, or `null` when Python is unavailable / the
183
220
  * subprocess failed / output was unparseable (caller falls back to regex).
184
221
  * An empty input returns `{}` (nothing to do, but Python IS available).
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Deterministic task-specific context selection.
3
+ *
4
+ * @implements docguard.task-specific-agent-context#FR-001
5
+ * @implements docguard.task-specific-agent-context#FR-002
6
+ * @implements docguard.task-specific-agent-context#FR-003
7
+ * @implements docguard.task-specific-agent-context#FR-004
8
+ * @implements docguard.task-specific-agent-context#FR-005
9
+ * @implements docguard.task-specific-agent-context#FR-006
10
+ * @implements docguard.task-specific-agent-context#FR-007
11
+ * @implements docguard.task-specific-agent-context#FR-008
12
+ * @implements docguard.task-specific-agent-context#FR-009
13
+ */
14
+
15
+ import { basename } from 'node:path';
16
+ import { citedSources, contentHash, createEvidenceReader, evidenceDocs, gitEvidence } from './semantic-claims.mjs';
17
+ import { compileGlob } from '../shared-ignore.mjs';
18
+
19
+ const LIMITS = Object.freeze({
20
+ taskChars: 2000,
21
+ documents: 32,
22
+ chunks: 256,
23
+ selectedExcerpts: 6,
24
+ linesPerExcerpt: 16,
25
+ totalChars: 6000,
26
+ pointers: 8,
27
+ });
28
+ const SCORE_THRESHOLD = 12;
29
+ const STOP = new Set('a an and are as at be by can change cli create do docguard for from has have how i improve in into is it make mjs of on or preserve should that the this to update use want when with you your'.split(' '));
30
+ const FINDING_RE = /\b[A-Z]{2,5}\d{3}\b/g;
31
+ const QUALIFIED_REQ_RE = /\b[a-z0-9][a-z0-9._/-]{2,127}#(?:FR|SC|NFR)-\d{3}\b/gi;
32
+ const REQUIREMENT_RE = /\b(?:FR|SC|NFR)-\d{3}\b/g;
33
+
34
+ function normalizeTask(task) {
35
+ if (typeof task !== 'string') throw new Error('Task context requires a text task.');
36
+ const normalized = task.replace(/\0/g, '').replace(/\s+/g, ' ').trim();
37
+ if (!normalized) throw new Error('Task context requires a non-empty task.');
38
+ if (normalized.length > LIMITS.taskChars) throw new Error(`Task context is limited to ${LIMITS.taskChars} characters.`);
39
+ return normalized;
40
+ }
41
+
42
+ function words(text) {
43
+ return [...new Set((String(text).toLowerCase().match(/[a-z][a-z0-9_-]{2,}/g) || [])
44
+ .filter(word => !STOP.has(word)))].sort();
45
+ }
46
+
47
+ function exactTokens(text) {
48
+ return [...new Set(String(text).match(/[A-Za-z_$][A-Za-z0-9_$.-]{2,}|(?:[^\s]+\/)+[^\s]+/g) || [])]
49
+ .filter(token => !STOP.has(token.toLowerCase())
50
+ && (/[._/$-]/.test(token) || /[A-Z]/.test(token.slice(1)) || /\d/.test(token)))
51
+ .sort();
52
+ }
53
+
54
+ function ignored(path, config) {
55
+ for (const pattern of config.ignore || []) {
56
+ try { if (compileGlob(pattern).test(path)) return true; } catch { /* invalid config is handled elsewhere */ }
57
+ }
58
+ return false;
59
+ }
60
+
61
+ function safeRegistry(read) {
62
+ const loaded = read('.docguard-specs.json');
63
+ if (loaded.content == null) return { active: [], excluded: 0, issue: 'spec-registry-unavailable' };
64
+ try {
65
+ const registry = JSON.parse(loaded.content);
66
+ if (registry.schemaVersion !== 2 || !Array.isArray(registry.specs)) throw new Error('schema');
67
+ const active = [];
68
+ let excluded = 0;
69
+ for (const entry of registry.specs) {
70
+ const lifecycle = entry?.reviewed?.lifecycle;
71
+ if (lifecycle?.context !== 'current' || lifecycle?.approval !== 'approved') { excluded++; continue; }
72
+ const artifact = entry?.observed?.artifacts?.find(item => item.path === entry.path);
73
+ const spec = read(entry.path);
74
+ if (!artifact?.digest || spec.content == null || contentHash(spec.content) !== artifact.digest) { excluded++; continue; }
75
+ active.push(entry);
76
+ }
77
+ return { active: active.sort((a, b) => a.specId.localeCompare(b.specId)), excluded, issue: null };
78
+ } catch {
79
+ return { active: [], excluded: 0, issue: 'spec-registry-invalid' };
80
+ }
81
+ }
82
+
83
+ function chunks(path, kind, content, specId = null) {
84
+ const lines = content.split('\n');
85
+ const headings = [];
86
+ for (let index = 0; index < lines.length; index++) {
87
+ const match = lines[index].match(/^#{1,4}\s+(.+)$/);
88
+ if (match) headings.push({ index, heading: match[1].trim() });
89
+ }
90
+ if (!headings.length || headings[0].index !== 0) headings.unshift({ index: 0, heading: basename(path) });
91
+ const ranges = [];
92
+ for (let h = 0; h < headings.length; h++) {
93
+ const sectionStart = headings[h].index;
94
+ const sectionEnd = headings[h + 1]?.index ?? lines.length;
95
+ if (/^implementation outcomes$/i.test(headings[h].heading)) continue;
96
+ for (let start = sectionStart; start < sectionEnd; start += LIMITS.linesPerExcerpt) {
97
+ const end = Math.min(sectionEnd, start + LIMITS.linesPerExcerpt);
98
+ ranges.push({ path, kind, specId, heading: headings[h].heading, startLine: start + 1, endLine: end, content: lines.slice(start, end).join('\n') });
99
+ }
100
+ }
101
+ return ranges;
102
+ }
103
+
104
+ function scoreChunk(chunk, task) {
105
+ const lower = chunk.content.toLowerCase();
106
+ const heading = chunk.heading.toLowerCase();
107
+ const reasons = [];
108
+ let score = 0;
109
+ if (task.paths.includes(chunk.path)) { score += 120; reasons.push('exact-path'); }
110
+ for (const req of task.qualifiedRequirements) {
111
+ const split = req.lastIndexOf('#');
112
+ const scope = req.slice(0, split).toLowerCase();
113
+ const id = req.slice(split + 1).toUpperCase();
114
+ if (lower.includes(req.toLowerCase()) || (chunk.specId?.toLowerCase() === scope && chunk.content.includes(id))) {
115
+ score += 100;
116
+ reasons.push(`qualified-requirement:${req}`);
117
+ }
118
+ }
119
+ for (const code of task.findingCodes) {
120
+ if (chunk.content.includes(code)) { score += 80; reasons.push(`finding-code:${code}`); }
121
+ }
122
+ for (const token of task.exact) {
123
+ if (chunk.content.includes(token)) { score += 12; reasons.push(`exact-token:${token}`); }
124
+ }
125
+ for (const word of task.words) {
126
+ if (lower.includes(word)) {
127
+ score += heading.includes(word) ? 8 : 4;
128
+ reasons.push(`${heading.includes(word) ? 'heading' : 'term'}:${word}`);
129
+ }
130
+ }
131
+ const pathTerms = words(chunk.path);
132
+ const pathOverlap = pathTerms.filter(word => task.words.includes(word));
133
+ if (pathOverlap.length) {
134
+ score += 8 * pathOverlap.length;
135
+ reasons.push(...pathOverlap.map(word => `path-term:${word}`));
136
+ }
137
+ return { score, reasons: [...new Set(reasons)].sort() };
138
+ }
139
+
140
+ function taskSignals(normalized) {
141
+ const paths = [...new Set((normalized.match(/(?:^|\s|[`'"(])([^\s`'"()]+\/[A-Za-z0-9_.\/-]+)(?=$|\s|[`'"),.])/g) || [])
142
+ .map(value => value.trim().replace(/^[`'"(]+|[`'"),.]+$/g, '')))];
143
+ return {
144
+ normalized,
145
+ words: words(normalized),
146
+ exact: exactTokens(normalized),
147
+ paths,
148
+ findingCodes: [...new Set(normalized.match(FINDING_RE) || [])].sort(),
149
+ qualifiedRequirements: [...new Set(normalized.match(QUALIFIED_REQ_RE) || [])].sort(),
150
+ };
151
+ }
152
+
153
+ function requirementIds(chunk) {
154
+ const ids = [...new Set(chunk.content.match(REQUIREMENT_RE) || [])];
155
+ return chunk.specId ? ids.map(id => `${chunk.specId}#${id}`) : [];
156
+ }
157
+
158
+ function pointerRecords(selected, registry, read, task) {
159
+ const candidates = new Map();
160
+ const push = (path, kind, reason, requirement = null, priority = 3) => {
161
+ if (!path) return;
162
+ const result = read(path);
163
+ if (result.evidence.status !== 'snapshot') return;
164
+ const existing = candidates.get(path);
165
+ if (existing) {
166
+ if (!existing.reasons.includes(reason)) existing.reasons.push(reason);
167
+ if (requirement && !existing.requirements.includes(requirement)) existing.requirements.push(requirement);
168
+ if (priority < existing.priority) { existing.priority = priority; existing.kind = kind; }
169
+ return;
170
+ }
171
+ candidates.set(path, { path, kind, requirements: requirement ? [requirement] : [], reasons: [reason], hash: result.evidence.hash, priority });
172
+ };
173
+ for (const path of task.paths) push(path, 'task-path', 'named-by-task', null, 0);
174
+ const explicitRequirements = new Set(task.qualifiedRequirements.map(value => value.toLowerCase()));
175
+ for (const excerpt of selected) {
176
+ for (const path of citedSources(excerpt.content)) {
177
+ const clean = path.replace(/:\d+(?:-\d+)?$/, '');
178
+ const relevant = task.paths.includes(clean) || words(clean).some(word => task.words.includes(word));
179
+ if (relevant) push(clean, 'cited-source', `cited-by:${excerpt.path}`, null, 3);
180
+ }
181
+ for (const identity of requirementIds(excerpt)) {
182
+ if (explicitRequirements.size && !explicitRequirements.has(identity.toLowerCase())) continue;
183
+ const split = identity.lastIndexOf('#');
184
+ const specId = identity.slice(0, split);
185
+ const id = identity.slice(split + 1);
186
+ const spec = registry.find(item => item.specId === specId);
187
+ for (const evidence of spec?.observed?.implementationEvidence || []) {
188
+ if (evidence.requirementId === id) push(evidence.file, 'implementation', `implements:${identity}`, identity, explicitRequirements.size ? 1 : 2);
189
+ }
190
+ for (const evidence of spec?.observed?.testEvidence || []) {
191
+ if (evidence.requirementId === id) push(evidence.file, 'test', `tests:${identity}`, identity, explicitRequirements.size ? 1 : 2);
192
+ }
193
+ }
194
+ }
195
+ return [...candidates.values()]
196
+ .sort((a, b) => a.priority - b.priority || a.path.localeCompare(b.path))
197
+ .slice(0, LIMITS.pointers)
198
+ .map(({ priority: _priority, ...item }) => ({
199
+ ...item,
200
+ requirements: item.requirements.sort(),
201
+ reasons: item.reasons.sort(),
202
+ }));
203
+ }
204
+
205
+ function removeContent(excerpt) {
206
+ const { content: _content, ...rest } = excerpt;
207
+ return rest;
208
+ }
209
+
210
+ /** Build a bounded packet or an honest abstention; never writes repository state. */
211
+ export function buildTaskContextPacket(projectDir, config = {}, taskText) {
212
+ const normalized = normalizeTask(taskText);
213
+ const signal = taskSignals(normalized);
214
+ const read = createEvidenceReader(projectDir);
215
+ const lifecycle = safeRegistry(read);
216
+ const docPaths = evidenceDocs(projectDir, config).filter(path => !ignored(path, config)).slice(0, LIMITS.documents);
217
+ const safeCanonicalDocs = [];
218
+ const candidates = [];
219
+ const inventory = [];
220
+ const add = (path, kind, specId = null) => {
221
+ if (inventory.includes(path) || inventory.length >= LIMITS.documents) return false;
222
+ const result = read(path);
223
+ if (result.content == null) return false;
224
+ inventory.push(path);
225
+ candidates.push(...chunks(path, kind, result.content, specId));
226
+ return true;
227
+ };
228
+ for (const path of docPaths) if (add(path, 'canonical')) safeCanonicalDocs.push(path);
229
+ for (const spec of lifecycle.active) add(spec.path, 'active-spec', spec.specId);
230
+ if (!ignored('AGENTS.md', config)) add('AGENTS.md', 'project-rules');
231
+
232
+ const ranked = candidates.slice(0, LIMITS.chunks).map(chunk => ({ ...chunk, ...scoreChunk(chunk, signal) }))
233
+ .filter(chunk => chunk.score >= SCORE_THRESHOLD)
234
+ .sort((a, b) => b.score - a.score || a.path.localeCompare(b.path) || a.startLine - b.startLine);
235
+ const selected = [];
236
+ let chars = 0;
237
+ let truncated = candidates.length > LIMITS.chunks;
238
+ for (const candidate of ranked) {
239
+ if (selected.length >= LIMITS.selectedExcerpts) { truncated = true; break; }
240
+ if (selected.filter(item => item.path === candidate.path).length >= 2) continue;
241
+ if (selected.some(item => item.path === candidate.path && !(candidate.endLine < item.startLine || candidate.startLine > item.endLine))) continue;
242
+ const remaining = LIMITS.totalChars - chars;
243
+ if (remaining <= 0) { truncated = true; break; }
244
+ const content = candidate.content.length > remaining ? candidate.content.slice(0, remaining) : candidate.content;
245
+ if (content.length < candidate.content.length) truncated = true;
246
+ const file = read(candidate.path).evidence;
247
+ selected.push({
248
+ path: candidate.path,
249
+ kind: candidate.kind,
250
+ specId: candidate.specId,
251
+ heading: candidate.heading,
252
+ startLine: candidate.startLine,
253
+ endLine: candidate.endLine,
254
+ fileHash: file.hash,
255
+ excerptHash: contentHash(content),
256
+ score: candidate.score,
257
+ reasons: candidate.reasons,
258
+ content,
259
+ });
260
+ chars += content.length;
261
+ }
262
+ if (ranked.length > selected.length) truncated = true;
263
+
264
+ const status = selected.length ? 'targeted' : 'abstained';
265
+ const limitations = [
266
+ 'Selection is deterministic retrieval, not proof that selected prose or omitted files are correct.',
267
+ 'Agents may inspect additional repository evidence before editing.',
268
+ ];
269
+ if (lifecycle.issue) limitations.push(lifecycle.issue);
270
+ if (truncated) limitations.push('selection-budget-reached');
271
+ if (status === 'abstained') limitations.push('no-candidate-met-relevance-threshold');
272
+ const pointers = status === 'targeted' ? pointerRecords(selected, lifecycle.active, read, signal) : [];
273
+ const publicExcerpts = status === 'targeted' ? selected : [];
274
+ return {
275
+ schemaVersion: 1,
276
+ kind: 'docguard.task-context',
277
+ task: { digest: contentHash(normalized), characters: normalized.length },
278
+ provenance: { git: gitEvidence(projectDir), registry: lifecycle.issue ? 'unavailable' : 'snapshot' },
279
+ assurance: { scope: 'retrieval-only', factualAccuracy: null, verification: 'unverified' },
280
+ selection: {
281
+ status,
282
+ threshold: SCORE_THRESHOLD,
283
+ candidatesConsidered: Math.min(candidates.length, LIMITS.chunks),
284
+ selectedExcerpts: publicExcerpts.length,
285
+ omittedCandidates: Math.max(0, ranked.length - publicExcerpts.length),
286
+ excludedLifecycleDocuments: lifecycle.excluded,
287
+ limits: LIMITS,
288
+ truncated,
289
+ },
290
+ excerpts: publicExcerpts,
291
+ pointers,
292
+ verification: [
293
+ { command: 'docguard guard --format json', purpose: 'Resolve deterministic errors and triage warnings.' },
294
+ ...(pointers.some(item => item.kind === 'test')
295
+ ? [{ command: 'Run the repository tests that cover the selected test pointers.', purpose: 'Verify task behavior and existing behavior.' }]
296
+ : []),
297
+ ],
298
+ navigation: {
299
+ canonicalDocs: safeCanonicalDocs.sort(),
300
+ activeSpecs: lifecycle.active.map(spec => ({ specId: spec.specId, path: spec.path })),
301
+ },
302
+ limitations,
303
+ coreDigest: contentHash(JSON.stringify({
304
+ task: contentHash(normalized), status,
305
+ excerpts: publicExcerpts.map(removeContent), pointers,
306
+ inventory: inventory.sort(), limitations,
307
+ })),
308
+ };
309
+ }
310
+
311
+ export const TASK_CONTEXT_LIMITS = LIMITS;
312
+ export const TASK_CONTEXT_THRESHOLD = SCORE_THRESHOLD;
@@ -1,5 +1,5 @@
1
1
  /** Explicit document roles let existing repository layouts serve as canonical input. */
2
- import { lstatSync } from 'node:fs';
2
+ import { existsSync, lstatSync, readFileSync } from 'node:fs';
3
3
  import { resolve, isAbsolute, join } from 'node:path';
4
4
  export const DOC_ROLES = Object.freeze({
5
5
  architecture: 'docs-canonical/ARCHITECTURE.md', dataModel: 'docs-canonical/DATA-MODEL.md',
@@ -52,6 +52,49 @@ export function remapDocPath(config, path) {
52
52
  return role ? docRolePath(config, role) : path;
53
53
  }
54
54
 
55
+ export function mappedRolesForPath(config = {}, path) {
56
+ const target = String(path).replace(/\\/g, '/').replace(/^\.\//, '');
57
+ return Object.keys(DOC_ROLES).filter(role => {
58
+ const configured = config.docs?.roles?.[role];
59
+ return configured !== undefined && docRolePath(config, role) === target && target !== DOC_ROLES[role];
60
+ });
61
+ }
62
+
63
+ export function isMappedDocPath(config = {}, path) {
64
+ return mappedRolesForPath(config, path).length > 0;
65
+ }
66
+
67
+ /**
68
+ * Authorize a whole-document write to custom role targets. New files are safe;
69
+ * existing files must explicitly grant full ownership and one file cannot be
70
+ * the whole-document target of several roles.
71
+ * @implements docguard.language-repository-coverage#FR-010
72
+ * @implements docguard.language-repository-coverage#FR-011
73
+ */
74
+ export function assertMappedFullDocumentWrites(projectDir, config = {}, roles = Object.keys(DOC_ROLES)) {
75
+ const selected = roles.filter(role => Object.hasOwn(DOC_ROLES, role) && config.docs?.roles?.[role] !== undefined
76
+ && docRolePath(config, role) !== DOC_ROLES[role]);
77
+ const paths = new Map();
78
+ for (const role of selected) {
79
+ const rel = docRolePath(config, role);
80
+ if (!paths.has(rel)) paths.set(rel, []);
81
+ paths.get(rel).push(role);
82
+ }
83
+ for (const [rel, owners] of paths) {
84
+ const allOwners = mappedRolesForPath(config, rel);
85
+ if (allOwners.length > 1) {
86
+ throw new Error(`Mapped document ${rel} serves multiple roles (${allOwners.join(', ')}); whole-document generation is unavailable. Use unique source=code sections instead.`);
87
+ }
88
+ const role = owners[0];
89
+ const full = resolveDocRole(projectDir, config, role);
90
+ if (!existsSync(full)) continue;
91
+ const content = readFileSync(full, 'utf8');
92
+ if (!/^[ \t]*<!--\s*docguard:generated\s+true\s*-->[ \t]*$/mi.test(content)) {
93
+ throw new Error(`Mapped document ${rel} is not fully owned by DocGuard. Add unique source=code sections for bounded writes; --force cannot overwrite its prose.`);
94
+ }
95
+ }
96
+ }
97
+
55
98
  export function assertDefaultDocWrites(config) {
56
99
  if (Object.entries(config.docs?.roles || {}).some(([role, path]) => path !== DOC_ROLES[role])) {
57
100
  throw new Error('Custom docs.roles currently support validation and read-only planning. Automatic document generation/repair is unavailable for mapped layouts; review and edit the existing documents directly.');