docguard-cli 0.39.0 → 0.40.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 +43 -20
- package/cli/commands/agent.mjs +47 -1
- package/cli/commands/explain.mjs +16 -0
- package/cli/commands/fix.mjs +13 -11
- package/cli/commands/generate.mjs +52 -18
- package/cli/commands/guard.mjs +13 -2
- package/cli/commands/mcp.mjs +22 -2
- package/cli/commands/score.mjs +13 -1
- package/cli/commands/sync.mjs +20 -7
- package/cli/commands/verify.mjs +65 -2
- package/cli/config.mjs +3 -0
- package/cli/docguard.mjs +33 -12
- package/cli/evidence/adapters.mjs +200 -0
- package/cli/evidence/evaluate.mjs +185 -0
- package/cli/evidence/manifest.mjs +194 -0
- package/cli/evidence/markdown.mjs +107 -0
- package/cli/findings.mjs +31 -0
- package/cli/release-pr-policy.mjs +107 -0
- package/cli/repository-root.mjs +159 -0
- package/cli/scanners/py-ast.mjs +39 -2
- package/cli/scanners/task-context.mjs +312 -0
- package/cli/shared-doc-roles.mjs +44 -1
- package/cli/shared-source.mjs +101 -28
- package/cli/validators/architecture.mjs +186 -13
- package/cli/validators/environment.mjs +14 -1
- package/cli/validators/evidence.mjs +52 -0
- package/cli/validators/todo-tracking.mjs +45 -2
- package/cli/writers/doc-generators.mjs +31 -17
- package/cli/writers/mechanical.mjs +44 -14
- package/cli/writers/sections.mjs +31 -3
- package/docs/ai-integration.md +31 -6
- package/docs/commands.md +43 -5
- package/docs/configuration.md +11 -3
- package/docs/quickstart.md +1 -1
- package/extensions/spec-kit-docguard/commands/fix.md +4 -2
- package/extensions/spec-kit-docguard/commands/generate.md +6 -1
- package/extensions/spec-kit-docguard/commands/guard.md +3 -2
- package/extensions/spec-kit-docguard/commands/sync.md +1 -1
- package/extensions/spec-kit-docguard/extension.yml +1 -1
- package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +14 -3
- package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +16 -5
- package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +8 -3
- package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +3 -2
- package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +6 -3
- package/extensions/spec-kit-docguard/templates/github-workflows/docguard-autofix.yml +2 -2
- package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +4 -4
- package/package.json +2 -1
- package/schemas/docguard-agent-context-benchmark.schema.json +92 -0
- package/schemas/docguard-agent-context-result.schema.json +95 -0
- package/schemas/docguard-config.schema.json +1 -0
- package/schemas/docguard-evidence.schema.json +169 -0
- package/schemas/docguard-task-context.schema.json +144 -0
- package/templates/AGENTS.md.template +9 -4
- package/templates/ci/github-actions.yml +4 -4
- package/templates/commands/docguard.guard.md +5 -1
- package/templates/commands/docguard.review.md +6 -1
- package/templates/evidence-manifest.json +21 -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
|
+
}
|
package/cli/scanners/py-ast.mjs
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/cli/shared-doc-roles.mjs
CHANGED
|
@@ -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.');
|