docguard-cli 0.35.0 → 0.36.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.
- 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/extension.yml +2 -3
- package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
- 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 { docRolePath } from '../shared-doc-roles.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* Semantic claim extractor (LLM field report #5).
|
|
3
4
|
*
|
|
@@ -21,10 +22,12 @@
|
|
|
21
22
|
* Zero npm dependencies — pure Node.js built-ins.
|
|
22
23
|
*/
|
|
23
24
|
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
25
|
+
import { lstatSync, realpathSync, readdirSync, openSync, readSync, fstatSync, closeSync, constants } from 'node:fs';
|
|
26
|
+
import { createHash } from 'node:crypto';
|
|
27
|
+
import { execFileSync } from 'node:child_process';
|
|
28
|
+
import { resolve, relative, isAbsolute, sep } from 'node:path';
|
|
26
29
|
import { loadIgnorePatterns } from '../shared.mjs';
|
|
27
|
-
|
|
30
|
+
|
|
28
31
|
|
|
29
32
|
// Numbers are only claims when adjacent to a recognized unit.
|
|
30
33
|
const NUMBER_PATTERNS = [
|
|
@@ -44,28 +47,169 @@ const NUMBER_PATTERNS = [
|
|
|
44
47
|
const ENUM_LIST_RE = /\b[A-Z][A-Z0-9_]{2,}(?:\s*(?:\/|,|\||\bor\b)\s*[A-Z][A-Z0-9_]{2,}){1,}\b/g;
|
|
45
48
|
const ENUM_CONTEXT_RE = /\b(status|state|enum|values?|one of|phase|stage|transitions?)\b/i;
|
|
46
49
|
|
|
47
|
-
|
|
48
|
-
const CITED_CODE_RE = /`?([\w./-]+\.(?:ts|tsx|js|mjs|cjs|jsx|py|go|rs|java|kt|rb|php|sql|yaml|yml|json))`?(?::(\d+))?/;
|
|
50
|
+
export const SEMANTIC_COVERAGE_LIMITATION = 'Semantic claim discovery is limited to docs-canonical/**/*.md, explicitly mapped Markdown document roles, README.md, and AGENTS.md, subject to ignores, safety checks, and scan budgets. Other resolved documentation homes are unscanned/unsupported by this extractor; guard coverage labels do not extend this scope. Hashes cover only captured document and cited-source inputs, not all documentation or factual correctness.';
|
|
49
51
|
|
|
50
52
|
const MAX_CLAIMS = 80;
|
|
53
|
+
const MAX_EVIDENCE_FILE_BYTES = 1024 * 1024;
|
|
54
|
+
const MAX_EVIDENCE_BYTES = 8 * MAX_EVIDENCE_FILE_BYTES;
|
|
55
|
+
const MAX_EVIDENCE_FILES = 128;
|
|
56
|
+
const MAX_CITED_SOURCES = 8;
|
|
57
|
+
|
|
58
|
+
export function contentHash(content) {
|
|
59
|
+
return `sha256:${createHash('sha256').update(content).digest('hex')}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function unknownFile(path, reason) {
|
|
63
|
+
return { path: path || null, status: 'unknown', hash: null, reason };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function privateSegment(part) {
|
|
67
|
+
return part.toLowerCase() === '.local' || /^\.env(?:\.|$)/i.test(part);
|
|
68
|
+
}
|
|
51
69
|
|
|
52
|
-
/**
|
|
53
|
-
function
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
70
|
+
/** Per-run bounded cache. Never follows symlinks, reads secrets, or leaves root. */
|
|
71
|
+
export function createEvidenceReader(projectDir) {
|
|
72
|
+
let root;
|
|
73
|
+
try { root = realpathSync(projectDir); } catch { /* unknown root */ }
|
|
74
|
+
const cache = new Map();
|
|
75
|
+
let bytes = 0;
|
|
76
|
+
return (citation) => {
|
|
77
|
+
const path = typeof citation === 'string' ? citation.replace(/:\d+(?:-\d+)?$/, '') : null;
|
|
78
|
+
if (!path) return { evidence: unknownFile(path, 'not-cited'), content: null };
|
|
79
|
+
if (cache.has(path)) return cache.get(path);
|
|
80
|
+
const fail = (reason) => ({ evidence: unknownFile(path, reason), content: null });
|
|
81
|
+
if (!root || isAbsolute(path) || /[\\:\0]/.test(path) || path.split('/').some(p => p === '..' || privateSegment(p))) {
|
|
82
|
+
return fail('unsafe-path');
|
|
83
|
+
}
|
|
84
|
+
if (cache.size >= MAX_EVIDENCE_FILES) return fail('file-budget');
|
|
85
|
+
let result;
|
|
86
|
+
let fd;
|
|
87
|
+
try {
|
|
88
|
+
let full = root;
|
|
89
|
+
let inspected;
|
|
90
|
+
for (const part of path.split('/').filter(p => p && p !== '.')) {
|
|
91
|
+
full = resolve(full, part);
|
|
92
|
+
inspected = lstatSync(full);
|
|
93
|
+
if (inspected.isSymbolicLink()) throw new Error('symlink');
|
|
94
|
+
}
|
|
95
|
+
const actual = realpathSync(full);
|
|
96
|
+
const rel = relative(root, actual);
|
|
97
|
+
if (!rel || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel) || rel.split(sep).some(privateSegment)) {
|
|
98
|
+
throw new Error('unsafe-path');
|
|
99
|
+
}
|
|
100
|
+
fd = openSync(actual, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
101
|
+
const stat = fstatSync(fd);
|
|
102
|
+
if (!stat.isFile()) throw new Error('not-file');
|
|
103
|
+
if (stat.dev !== inspected?.dev || stat.ino !== inspected?.ino) throw new Error('changed-during-read');
|
|
104
|
+
if (stat.size > MAX_EVIDENCE_FILE_BYTES) throw new Error('file-too-large');
|
|
105
|
+
if (bytes + stat.size > MAX_EVIDENCE_BYTES) throw new Error('byte-budget');
|
|
106
|
+
const buffer = Buffer.alloc(stat.size);
|
|
107
|
+
let offset = 0;
|
|
108
|
+
while (offset < buffer.length) {
|
|
109
|
+
const n = readSync(fd, buffer, offset, buffer.length - offset, offset);
|
|
110
|
+
if (!n) break;
|
|
111
|
+
offset += n;
|
|
112
|
+
}
|
|
113
|
+
bytes += offset;
|
|
114
|
+
const after = fstatSync(fd);
|
|
115
|
+
if (offset !== stat.size || after.size !== stat.size || after.mtimeMs !== stat.mtimeMs || after.ctimeMs !== stat.ctimeMs) {
|
|
116
|
+
throw new Error('changed-during-read');
|
|
117
|
+
}
|
|
118
|
+
result = { evidence: { path, status: 'snapshot', hash: contentHash(buffer) }, content: buffer.toString('utf8') };
|
|
119
|
+
} catch (err) {
|
|
120
|
+
const reasons = ['symlink', 'unsafe-path', 'not-file', 'file-too-large', 'byte-budget', 'changed-during-read'];
|
|
121
|
+
result = fail(reasons.includes(err.message) ? err.message : 'unavailable');
|
|
122
|
+
} finally {
|
|
123
|
+
if (fd !== undefined) closeSync(fd);
|
|
124
|
+
}
|
|
125
|
+
cache.set(path, result);
|
|
126
|
+
return result;
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Candidate paths only; the reader decides whether they can be used safely. */
|
|
131
|
+
export function citedSources(text, limit = MAX_CITED_SOURCES) {
|
|
132
|
+
const paths = [];
|
|
133
|
+
for (const match of String(text || '').slice(0, 32768).matchAll(/`([^`\n]+)`|([^\s`]+)/g)) {
|
|
134
|
+
const candidate = match[1] || match[2].replace(/^[(["']+|[)\],;.!"']+$/g, '');
|
|
135
|
+
if (!/(?:\.(?:ts|tsx|js|mjs|cjs|jsx|py|go|rs|java|kt|rb|php|sql|yaml|yml|json)|(?:^|\/)\.env(?:\.[\w.-]+)?)(?::\d+(?:-\d+)?)?$/.test(candidate)) continue;
|
|
136
|
+
if (!paths.includes(candidate)) paths.push(candidate);
|
|
137
|
+
if (paths.length >= limit) break;
|
|
138
|
+
}
|
|
139
|
+
return paths;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Stable identity excludes line numbers, discovery order, and content hashes. */
|
|
143
|
+
export function semanticClaimId(claim) {
|
|
144
|
+
return `claim.${contentHash(JSON.stringify([
|
|
145
|
+
claim.doc, claim.section, claim.kind, claim.subkind, claim.value, claim.unit,
|
|
146
|
+
String(claim.text || '').replace(/\s+/g, ' ').trim(),
|
|
147
|
+
(claim.citedCode || '').replace(/:\d+(?:-\d+)?$/, ''),
|
|
148
|
+
])).slice(7)}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Hashes bind a snapshot to its inputs; they are never evidence of review. */
|
|
152
|
+
export function taskEvidence(read, doc, citations = [], taskContent = '') {
|
|
153
|
+
const document = read(doc).evidence;
|
|
154
|
+
const citedSourceFiles = [...new Set(citations)].slice(0, MAX_CITED_SOURCES).map(p => read(p).evidence);
|
|
155
|
+
const snapshot = { document, citedSources: citedSourceFiles, taskContentHash: contentHash(taskContent) };
|
|
156
|
+
return {
|
|
157
|
+
kind: 'snapshot', verification: 'unverified', factualAccuracy: 'unknown',
|
|
158
|
+
limitation: SEMANTIC_COVERAGE_LIMITATION,
|
|
159
|
+
...snapshot,
|
|
160
|
+
sourceCoverage: citedSourceFiles.length && citedSourceFiles.every(s => s.status === 'snapshot') ? 'cited-only' : 'unknown',
|
|
161
|
+
snapshotHash: contentHash(JSON.stringify(snapshot)),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Two bounded read-only Git queries per artifact, never per task. */
|
|
166
|
+
export function gitEvidence(projectDir) {
|
|
167
|
+
const result = { revision: null, dirty: null, status: 'unknown' };
|
|
168
|
+
const options = { cwd: projectDir, encoding: 'utf8', timeout: 1500, maxBuffer: 65536,
|
|
169
|
+
stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' } };
|
|
170
|
+
try {
|
|
171
|
+
const revision = execFileSync('git', ['rev-parse', '--verify', 'HEAD'], options).trim();
|
|
172
|
+
if (/^[a-f0-9]{40,64}$/.test(revision)) result.revision = revision;
|
|
173
|
+
} catch { /* missing Git/revision stays unknown */ }
|
|
174
|
+
try {
|
|
175
|
+
result.dirty = execFileSync('git', ['status', '--porcelain', '--untracked-files=normal'], options).length > 0;
|
|
176
|
+
} catch { /* dirty state unknown */ }
|
|
177
|
+
if (result.revision !== null && result.dirty !== null) result.status = 'snapshot';
|
|
178
|
+
return result;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Bounded canonical inventory that does not traverse private or symlink dirs. */
|
|
182
|
+
export function evidenceDocs(projectDir, config = {}) {
|
|
183
|
+
const docs = [];
|
|
184
|
+
let directories = 0;
|
|
185
|
+
const walk = (rel) => {
|
|
186
|
+
if (++directories > MAX_EVIDENCE_FILES || docs.length >= MAX_EVIDENCE_FILES) return;
|
|
187
|
+
try {
|
|
188
|
+
if (lstatSync(resolve(projectDir, rel)).isSymbolicLink()) return;
|
|
189
|
+
for (const entry of readdirSync(resolve(projectDir, rel), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
190
|
+
if (docs.length >= MAX_EVIDENCE_FILES) break;
|
|
191
|
+
if (entry.isSymbolicLink() || privateSegment(entry.name)) continue;
|
|
192
|
+
const path = `${rel}/${entry.name}`;
|
|
193
|
+
if (entry.isDirectory() && !entry.name.startsWith('.')) walk(path);
|
|
194
|
+
else if (entry.isFile() && /\.md$/i.test(entry.name)) docs.push(path);
|
|
195
|
+
}
|
|
196
|
+
} catch { /* inventory is heuristic, not proof of coverage */ }
|
|
197
|
+
};
|
|
198
|
+
walk('docs-canonical');
|
|
199
|
+
for (const role of Object.keys(config.docs?.roles || {})) {
|
|
200
|
+
const path = docRolePath(config, role);
|
|
201
|
+
if (/\.md$/i.test(path) && !docs.includes(path)) docs.push(path);
|
|
65
202
|
}
|
|
66
203
|
return docs;
|
|
67
204
|
}
|
|
68
205
|
|
|
206
|
+
/** Canonical docs + root docs, honoring the existing ignore contract. */
|
|
207
|
+
function claimSourceDocs(projectDir, read, config) {
|
|
208
|
+
// The legacy matcher reads its file itself; never call it for an unsafe file.
|
|
209
|
+
const isIgnored = read('.docguardignore').content === null ? () => false : loadIgnorePatterns(projectDir);
|
|
210
|
+
return [...evidenceDocs(projectDir, config), 'README.md', 'AGENTS.md'].filter(doc => !isIgnored(doc));
|
|
211
|
+
}
|
|
212
|
+
|
|
69
213
|
/** True if a line is inside a fenced code block (toggled by the caller). */
|
|
70
214
|
function findCitedCode(lines, idx) {
|
|
71
215
|
// Search the claim line first, then the immediately adjacent lines. A tight
|
|
@@ -74,8 +218,8 @@ function findCitedCode(lines, idx) {
|
|
|
74
218
|
for (let d = 0; d <= 1; d++) {
|
|
75
219
|
for (const j of d === 0 ? [idx] : [idx - d, idx + d]) {
|
|
76
220
|
if (j < 0 || j >= lines.length) continue;
|
|
77
|
-
const
|
|
78
|
-
if (
|
|
221
|
+
const cited = citedSources(lines[j], 1)[0];
|
|
222
|
+
if (cited) return cited;
|
|
79
223
|
}
|
|
80
224
|
}
|
|
81
225
|
return null;
|
|
@@ -85,13 +229,13 @@ function findCitedCode(lines, idx) {
|
|
|
85
229
|
* Extract semantic claims from a project's canonical docs.
|
|
86
230
|
* @returns {Array<{ doc, line, section, kind, subkind, value, unit, text, citedCode }>}
|
|
87
231
|
*/
|
|
88
|
-
export function extractSemanticClaims(projectDir, config = {}) {
|
|
232
|
+
export function extractSemanticClaims(projectDir, config = {}, read = createEvidenceReader(projectDir)) {
|
|
89
233
|
const claims = [];
|
|
90
234
|
const seen = new Set();
|
|
91
235
|
|
|
92
|
-
for (const doc of claimSourceDocs(projectDir)) {
|
|
93
|
-
|
|
94
|
-
|
|
236
|
+
for (const doc of claimSourceDocs(projectDir, read, config)) {
|
|
237
|
+
const { content } = read(doc);
|
|
238
|
+
if (content === null) continue;
|
|
95
239
|
const lines = content.split('\n');
|
|
96
240
|
let section = '';
|
|
97
241
|
let inFence = false;
|
|
@@ -108,7 +252,11 @@ export function extractSemanticClaims(projectDir, config = {}) {
|
|
|
108
252
|
const key = `${doc}:${lineNo}:${claim.kind}:${claim.value}:${claim.unit || ''}`;
|
|
109
253
|
if (seen.has(key)) return;
|
|
110
254
|
seen.add(key);
|
|
111
|
-
claims.
|
|
255
|
+
if (claims.length >= MAX_CLAIMS) return;
|
|
256
|
+
const candidate = { doc, line: lineNo, section, citedCode: findCitedCode(lines, i), text: line.trim().slice(0, 200), ...claim };
|
|
257
|
+
candidate.stableId = semanticClaimId({ ...candidate, text: line.trim() });
|
|
258
|
+
candidate.evidence = taskEvidence(read, doc, candidate.citedCode ? [candidate.citedCode] : [], line.trim());
|
|
259
|
+
claims.push(candidate);
|
|
112
260
|
};
|
|
113
261
|
|
|
114
262
|
for (const { kind, re } of NUMBER_PATTERNS) {
|
|
@@ -147,6 +295,8 @@ export function buildSemanticVerifyTasks(claims) {
|
|
|
147
295
|
: `the ${c.subkind} value ${c.value}${c.unit ? ` ${c.unit}` : ''}`;
|
|
148
296
|
return {
|
|
149
297
|
id: `verify.semantic.${i + 1}`,
|
|
298
|
+
stableId: c.stableId || semanticClaimId(c),
|
|
299
|
+
evidence: c.evidence || taskEvidence(path => ({ evidence: unknownFile(path, 'not-captured') }), c.doc, c.citedCode ? [c.citedCode] : []),
|
|
150
300
|
doc: c.doc,
|
|
151
301
|
line: c.line,
|
|
152
302
|
section: c.section,
|
|
@@ -155,7 +305,7 @@ export function buildSemanticVerifyTasks(claims) {
|
|
|
155
305
|
unit: c.unit,
|
|
156
306
|
citedCode: c.citedCode,
|
|
157
307
|
claim: c.text,
|
|
158
|
-
instruction: `Verify ${what} documented in ${c.doc}:${c.line}${c.section ? ` (section "${c.section}")` : ''} against the code.${where} If the code disagrees, the doc (or the code) is wrong — report the mismatch with both values.`,
|
|
308
|
+
instruction: `Verify ${what} documented in ${c.doc}:${c.line}${c.section ? ` (section "${c.section}")` : ''} against the code.${where} If the code disagrees, the doc (or the code) is wrong — report the mismatch with both values. Attached hashes capture an unverified snapshot; guard does not verify this claim.`,
|
|
159
309
|
confidence: 'requires-human',
|
|
160
310
|
};
|
|
161
311
|
});
|
package/cli/shared-diff.mjs
CHANGED
|
@@ -169,7 +169,7 @@ export function activityLabeledDiff(fileDiff) {
|
|
|
169
169
|
}
|
|
170
170
|
|
|
171
171
|
/**
|
|
172
|
-
*
|
|
172
|
+
* Old-side tokens absent from additions, plus removed declaration names, from every '-' line
|
|
173
173
|
* (both pure deletes and the "old" side of replaces). This is the
|
|
174
174
|
* `deleted ∪ replaceOld` span the outdated-comment research keys on: a doc
|
|
175
175
|
* that still talks about these tokens is a drift suspect.
|
|
@@ -181,6 +181,27 @@ export function removedTokens(fileDiff, opts) {
|
|
|
181
181
|
if (ln.op === '-') for (const t of tokenize(ln.text, opts)) set.add(t);
|
|
182
182
|
}
|
|
183
183
|
}
|
|
184
|
+
// Reformatting or movement into another hunk does not remove a token.
|
|
185
|
+
for (const token of addedTokens(fileDiff, opts)) set.delete(token);
|
|
186
|
+
// A retained call is not a retained declaration. Preserve name evidence when
|
|
187
|
+
// declarations disappear, even if additions still reference those names.
|
|
188
|
+
const declarationNames = op => {
|
|
189
|
+
const text = (fileDiff.hunks || []).flatMap(h => h.lines)
|
|
190
|
+
.filter(line => line.op === op).map(line => line.text).join('\n');
|
|
191
|
+
const names = new Map();
|
|
192
|
+
const pattern = /\b(?:function\s*\*?\s*|def\s+|class\s+|(?:const|let|var)\s+)([A-Za-z_$][\w$]*)/g;
|
|
193
|
+
for (const match of text.matchAll(pattern)) {
|
|
194
|
+
names.set(match[1], (names.get(match[1]) || 0) + 1);
|
|
195
|
+
}
|
|
196
|
+
return names;
|
|
197
|
+
};
|
|
198
|
+
const declarationsAdded = declarationNames('+');
|
|
199
|
+
for (const [name, count] of declarationNames('-')) {
|
|
200
|
+
if (count > (declarationsAdded.get(name) || 0)) {
|
|
201
|
+
for (const token of tokenize(name, opts)) set.add(token);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
184
205
|
return set;
|
|
185
206
|
}
|
|
186
207
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** Explicit document roles let existing repository layouts serve as canonical input. */
|
|
2
|
+
import { lstatSync } from 'node:fs';
|
|
3
|
+
import { resolve, isAbsolute, join } from 'node:path';
|
|
4
|
+
export const DOC_ROLES = Object.freeze({
|
|
5
|
+
architecture: 'docs-canonical/ARCHITECTURE.md', dataModel: 'docs-canonical/DATA-MODEL.md',
|
|
6
|
+
security: 'docs-canonical/SECURITY.md', testSpec: 'docs-canonical/TEST-SPEC.md',
|
|
7
|
+
environment: 'docs-canonical/ENVIRONMENT.md', apiReference: 'docs-canonical/API-REFERENCE.md',
|
|
8
|
+
requirements: 'docs-canonical/REQUIREMENTS.md',
|
|
9
|
+
});
|
|
10
|
+
export function docRolePath(config = {}, role) {
|
|
11
|
+
if (!Object.hasOwn(DOC_ROLES, role)) throw new Error('Unknown document role: ' + role);
|
|
12
|
+
const value = config.docs?.roles?.[role] ?? DOC_ROLES[role];
|
|
13
|
+
if (typeof value !== 'string' || !value.trim() || value.includes('\0')) throw new Error('Invalid document path for role: ' + role);
|
|
14
|
+
const path = value.replace(/\\/g, '/');
|
|
15
|
+
if (config.docs?.roles?.[role] !== undefined && !path.toLowerCase().endsWith('.md')) throw new Error('Mapped document roles currently require Markdown (.md): ' + role);
|
|
16
|
+
if (isAbsolute(path) || /^[A-Za-z]:/.test(path) || path.split('/').some(p => p === '..' || p.toLowerCase() === '.local' || p.toLowerCase() === '.git')) throw new Error('Document role path must stay within the project: ' + role);
|
|
17
|
+
return path.startsWith('./') ? path.slice(2) : path;
|
|
18
|
+
}
|
|
19
|
+
export function resolveDocRole(projectDir, config, role) {
|
|
20
|
+
const rel = docRolePath(config, role);
|
|
21
|
+
// Opt-in mappings must not follow links into another repository/private data.
|
|
22
|
+
if (config.docs?.roles?.[role] !== undefined) {
|
|
23
|
+
let current = resolve(projectDir);
|
|
24
|
+
for (const part of rel.split('/')) {
|
|
25
|
+
current = join(current, part);
|
|
26
|
+
try { if (lstatSync(current).isSymbolicLink()) throw new Error('Document role path contains a symlink: ' + role); }
|
|
27
|
+
catch (e) { if (e.code !== 'ENOENT') throw e; }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return resolve(projectDir, rel);
|
|
31
|
+
}
|
|
32
|
+
export function applyDocRoles(projectDir, config) {
|
|
33
|
+
const roles = config.docs?.roles;
|
|
34
|
+
if (roles === undefined) return config;
|
|
35
|
+
if (!roles || typeof roles !== 'object' || Array.isArray(roles)) throw new Error('docs.roles must be an object');
|
|
36
|
+
const replacements = new Map();
|
|
37
|
+
for (const role of Object.keys(roles)) {
|
|
38
|
+
resolveDocRole(projectDir, config, role);
|
|
39
|
+
replacements.set(DOC_ROLES[role], docRolePath(config, role));
|
|
40
|
+
}
|
|
41
|
+
const requiredFiles = { ...config.requiredFiles, canonical: [...new Set([...(config.requiredFiles?.canonical || []).map(p => replacements.get(p) || p), ...replacements.values()])] };
|
|
42
|
+
const documentTypes = { ...config.documentTypes };
|
|
43
|
+
for (const [oldPath, newPath] of replacements) {
|
|
44
|
+
const old = documentTypes[oldPath] || {};
|
|
45
|
+
if (oldPath !== newPath) delete documentTypes[oldPath];
|
|
46
|
+
documentTypes[newPath] = { ...old, ...documentTypes[newPath], required: true, category: 'canonical' };
|
|
47
|
+
}
|
|
48
|
+
return { ...config, requiredFiles, documentTypes };
|
|
49
|
+
}
|
|
50
|
+
export function remapDocPath(config, path) {
|
|
51
|
+
const role = Object.keys(DOC_ROLES).find(key => DOC_ROLES[key] === path);
|
|
52
|
+
return role ? docRolePath(config, role) : path;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function assertDefaultDocWrites(config) {
|
|
56
|
+
if (Object.entries(config.docs?.roles || {}).some(([role, path]) => path !== DOC_ROLES[role])) {
|
|
57
|
+
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.');
|
|
58
|
+
}
|
|
59
|
+
}
|
package/cli/shared-ignore.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveDocRole } from './shared-doc-roles.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* Shared Ignore Utility — Unified file filtering for all validators.
|
|
3
4
|
*
|
|
@@ -384,11 +385,11 @@ export function walkFiles(dir, callback, opts = {}) {
|
|
|
384
385
|
export function listCanonicalDocs(projectDir, opts = {}) {
|
|
385
386
|
const { dirName = 'docs-canonical', isIgnored = null } = opts;
|
|
386
387
|
const root = resolvePath(projectDir, dirName);
|
|
387
|
-
|
|
388
|
+
// An explicit role may live outside the conventional canonical directory.
|
|
388
389
|
|
|
389
390
|
const isMarkdown = (name) => name.toLowerCase().endsWith('.md');
|
|
390
391
|
const out = [];
|
|
391
|
-
walkFiles(root, (abs) => {
|
|
392
|
+
if (existsSync(root)) walkFiles(root, (abs) => {
|
|
392
393
|
if (!isMarkdown(abs)) return;
|
|
393
394
|
const rel = relPosix(projectDir, abs);
|
|
394
395
|
if (isIgnored && isIgnored(rel)) return;
|
|
@@ -399,6 +400,18 @@ export function listCanonicalDocs(projectDir, opts = {}) {
|
|
|
399
400
|
keepDot: isMarkdown,
|
|
400
401
|
});
|
|
401
402
|
|
|
403
|
+
let roleConfig = opts.config;
|
|
404
|
+
if (!roleConfig) {
|
|
405
|
+
const configFile = resolvePath(projectDir, '.docguard.json');
|
|
406
|
+
try { if (existsSync(configFile)) roleConfig = JSON.parse(readFileSync(configFile, 'utf-8')); }
|
|
407
|
+
catch { /* loadConfig owns malformed configuration errors */ }
|
|
408
|
+
}
|
|
409
|
+
for (const role of Object.keys(roleConfig?.docs?.roles || {})) {
|
|
410
|
+
const abs = resolveDocRole(projectDir, roleConfig, role);
|
|
411
|
+
const rel = relPosix(projectDir, abs);
|
|
412
|
+
if (!existsSync(abs) || !isMarkdown(abs) || (isIgnored && isIgnored(rel))) continue;
|
|
413
|
+
if (!out.some(doc => doc.abs === abs)) out.push({ abs, rel });
|
|
414
|
+
}
|
|
402
415
|
out.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
|
|
403
416
|
return out;
|
|
404
417
|
}
|
package/cli/shared-source.mjs
CHANGED
|
@@ -10,9 +10,11 @@
|
|
|
10
10
|
* - pnpm-workspace.yaml (packages:)
|
|
11
11
|
* - turbo.json (presence → trust package.json workspaces)
|
|
12
12
|
*
|
|
13
|
-
*
|
|
13
|
+
* Source discovery uses Node.js built-ins; Worker binding analysis optionally
|
|
14
|
+
* loads the existing @babel/parser dependency, with a lexical fallback.
|
|
14
15
|
*/
|
|
15
16
|
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
16
18
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
17
19
|
import { resolve, join, dirname, relative, extname } from 'node:path';
|
|
18
20
|
import { shouldIgnore, isNonProductDir, isNonProductPath } from './shared-ignore.mjs';
|
|
@@ -316,6 +318,223 @@ export function isRunnerEnvVar(name) {
|
|
|
316
318
|
return RUNNER_ENV_PREFIXES.some((p) => name.startsWith(p));
|
|
317
319
|
}
|
|
318
320
|
|
|
321
|
+
|
|
322
|
+
/** Wrangler is static evidence only: never load or execute project config. */
|
|
323
|
+
export function hasWorkerConfig(dir) {
|
|
324
|
+
return ['wrangler.toml', 'wrangler.json', 'wrangler.jsonc'].some(name => {
|
|
325
|
+
try { return statSync(join(dir, name)).isFile(); } catch { return false; }
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function workerConfigForFile(projectDir, file) {
|
|
330
|
+
const root = resolve(projectDir);
|
|
331
|
+
let dir = dirname(file);
|
|
332
|
+
while (dir === root || (!relative(root, dir).startsWith('..') && !relative(root, dir).startsWith('/'))) {
|
|
333
|
+
if (hasWorkerConfig(dir)) return true;
|
|
334
|
+
if (dir === root) break;
|
|
335
|
+
dir = dirname(dir);
|
|
336
|
+
}
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Optional-load the existing parser directly: shared helpers must not depend
|
|
341
|
+
// on the scanner layer. No project modules or configuration are executed.
|
|
342
|
+
let workerParse = null;
|
|
343
|
+
try { workerParse = createRequire(import.meta.url)('@babel/parser').parse; } catch { /* lexical fallback */ }
|
|
344
|
+
|
|
345
|
+
function workerType(param) {
|
|
346
|
+
const type = param?.typeAnnotation?.typeAnnotation;
|
|
347
|
+
return type?.type === 'TSTypeReference' && type.typeName?.type === 'Identifier' ? type.typeName.name : '';
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Static lexical binding analysis; collect declarations before resolving reads. */
|
|
351
|
+
function workerAstBindings(ast, configured) {
|
|
352
|
+
const root = { parent: null, functionScope: true, env: undefined };
|
|
353
|
+
const reads = [];
|
|
354
|
+
const bind = (pattern, scope, value = false) => {
|
|
355
|
+
if (!pattern) return;
|
|
356
|
+
if (pattern.type === 'Identifier' && pattern.name === 'env') scope.env = value;
|
|
357
|
+
else if (pattern.type === 'AssignmentPattern') bind(pattern.left, scope, value);
|
|
358
|
+
else if (pattern.type === 'RestElement') bind(pattern.argument, scope, value);
|
|
359
|
+
else if (pattern.type === 'ArrayPattern') for (const element of pattern.elements) bind(element, scope, value);
|
|
360
|
+
else if (pattern.type === 'ObjectPattern') {
|
|
361
|
+
for (const property of pattern.properties) bind(property.type === 'RestElement' ? property.argument : property.value, scope, value);
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
const functionTypes = new Set(['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression', 'ObjectMethod', 'ClassMethod', 'ClassPrivateMethod']);
|
|
365
|
+
function visit(node, scope, parent) {
|
|
366
|
+
if (!node || typeof node.type !== 'string') return;
|
|
367
|
+
if (node.type === 'FunctionDeclaration' || node.type === 'ClassDeclaration') bind(node.id, scope);
|
|
368
|
+
if (functionTypes.has(node.type)) {
|
|
369
|
+
scope = { parent: scope, functionScope: true, env: undefined };
|
|
370
|
+
if (node.type === 'FunctionExpression') bind(node.id, scope);
|
|
371
|
+
const key = node.key?.name || node.key?.value || node.id?.name ||
|
|
372
|
+
(parent?.type === 'ObjectProperty' ? parent.key?.name || parent.key?.value : parent?.type === 'VariableDeclarator' ? parent.id?.name : '');
|
|
373
|
+
const request = workerType(node.params[0]) === 'Request';
|
|
374
|
+
for (const param of node.params) {
|
|
375
|
+
const target = param.type === 'AssignmentPattern' ? param.left : param;
|
|
376
|
+
const worker = target.type === 'Identifier' && target.name === 'env' &&
|
|
377
|
+
/^(?:Env|[\w$]*Env|[\w$]*Bindings)$/.test(workerType(target)) && (configured || (key === 'fetch' && request));
|
|
378
|
+
bind(param, scope, worker);
|
|
379
|
+
}
|
|
380
|
+
} else if (['BlockStatement', 'ForStatement', 'ForOfStatement', 'ForInStatement', 'CatchClause', 'SwitchStatement', 'ClassExpression', 'ClassDeclaration', 'StaticBlock'].includes(node.type)) {
|
|
381
|
+
scope = { parent: scope, functionScope: node.type === 'StaticBlock', env: undefined };
|
|
382
|
+
if (node.type === 'CatchClause') bind(node.param, scope);
|
|
383
|
+
if (node.type === 'ClassExpression' || node.type === 'ClassDeclaration') bind(node.id, scope);
|
|
384
|
+
}
|
|
385
|
+
if (node.type === 'VariableDeclaration') {
|
|
386
|
+
let declarationScope = scope;
|
|
387
|
+
if (node.kind === 'var') while (!declarationScope.functionScope && declarationScope.parent) declarationScope = declarationScope.parent;
|
|
388
|
+
for (const declaration of node.declarations) bind(declaration.id, declarationScope);
|
|
389
|
+
}
|
|
390
|
+
if (node.type === 'ImportDeclaration') for (const spec of node.specifiers) bind(spec.local, scope);
|
|
391
|
+
if ((node.type === 'MemberExpression' || node.type === 'OptionalMemberExpression') && node.object?.type === 'Identifier' && node.object.name === 'env') {
|
|
392
|
+
const name = node.computed ? (node.property.type === 'StringLiteral' ? node.property.value : null) : node.property.name;
|
|
393
|
+
if (name) reads.push({ scope, name });
|
|
394
|
+
}
|
|
395
|
+
for (const [key, child] of Object.entries(node)) {
|
|
396
|
+
if (['loc', 'start', 'end', 'extra', 'comments', 'tokens'].includes(key)) continue;
|
|
397
|
+
if (Array.isArray(child)) for (const item of child) visit(item, scope, node);
|
|
398
|
+
else if (child && typeof child === 'object') visit(child, scope, node);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
visit(ast.program, root, null);
|
|
402
|
+
const names = new Set();
|
|
403
|
+
for (const read of reads) {
|
|
404
|
+
let scope = read.scope;
|
|
405
|
+
while (scope && scope.env === undefined) scope = scope.parent;
|
|
406
|
+
if (scope?.env === true) names.add(read.name);
|
|
407
|
+
}
|
|
408
|
+
return names;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Optional parser argument makes the absent/failed-parser contract testable. */
|
|
412
|
+
export function extractWorkerEnvBindings(content, filename = 'file.ts', configured = false, parse = workerParse) {
|
|
413
|
+
if (parse) {
|
|
414
|
+
try {
|
|
415
|
+
const plugins = ['decorators-legacy', 'classProperties', 'topLevelAwait'];
|
|
416
|
+
if (/\.[cm]?tsx?$/.test(filename)) plugins.push('typescript');
|
|
417
|
+
if (/\.[jt]sx?$/.test(filename) && !filename.endsWith('.ts')) plugins.push('jsx');
|
|
418
|
+
const ast = parse(content, { sourceType: 'unambiguous', allowReturnOutsideFunction: true, plugins });
|
|
419
|
+
return workerAstBindings(ast, configured);
|
|
420
|
+
} catch { /* Failed parsing retains conservative lexical evidence. */ }
|
|
421
|
+
}
|
|
422
|
+
return workerEnvUsageFallback(content, classifyChars(content, extname(filename)), configured);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Resolve binding positions in simple lexical patterns, not property names.
|
|
426
|
+
// Defaults may refer to env without introducing a new local env binding.
|
|
427
|
+
function lexicalEnvBinding(pattern) {
|
|
428
|
+
const split = (text, delimiter) => {
|
|
429
|
+
const parts = []; let depth = 0, start = 0;
|
|
430
|
+
for (let i = 0; i < text.length; i++) {
|
|
431
|
+
if ('([{'.includes(text[i])) depth++;
|
|
432
|
+
else if (')]}'.includes(text[i])) depth--;
|
|
433
|
+
else if (text[i] === delimiter && depth === 0) { parts.push(text.slice(start, i)); start = i + 1; }
|
|
434
|
+
}
|
|
435
|
+
return [...parts, text.slice(start)];
|
|
436
|
+
};
|
|
437
|
+
const binding = text => {
|
|
438
|
+
text = split(text.trim().replace(/^\.\.\./, ''), '=')[0].trim();
|
|
439
|
+
if (text.startsWith('{') && text.endsWith('}')) {
|
|
440
|
+
return split(text.slice(1, -1), ',').some(property => {
|
|
441
|
+
const pair = split(property, ':');
|
|
442
|
+
return binding(pair.length > 1 ? pair.slice(1).join(':') : property);
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
if (text.startsWith('[') && text.endsWith(']')) return split(text.slice(1, -1), ',').some(binding);
|
|
446
|
+
return /^env\s*(?::[^]*)?$/.test(text);
|
|
447
|
+
};
|
|
448
|
+
return split(pattern, ',').some(binding);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Conservative, parser-independent support for ordinary typed function bodies. */
|
|
452
|
+
function workerEnvUsageFallback(content, kind, configured) {
|
|
453
|
+
// Mask regex literals where an expression may start. Division remains code.
|
|
454
|
+
for (let i = 0; i < content.length; i++) {
|
|
455
|
+
if (kind[i] || content[i] !== '/') continue;
|
|
456
|
+
const prefix = content.slice(0, i).trimEnd();
|
|
457
|
+
if (prefix && !/[=(:,[!&|?{};]$/.test(prefix) && !/(?:\b(?:return|throw|yield|case)|=>)$/.test(prefix)) continue;
|
|
458
|
+
let end = i + 1, bracket = false;
|
|
459
|
+
for (; end < content.length && content[end] !== '\n'; end++) {
|
|
460
|
+
if (content[end] === '\\') { end++; continue; }
|
|
461
|
+
if (content[end] === '[') bracket = true;
|
|
462
|
+
if (content[end] === ']') bracket = false;
|
|
463
|
+
if (content[end] === '/' && !bracket) break;
|
|
464
|
+
}
|
|
465
|
+
if (content[end] === '/') { kind.fill(1, i, end + 1); i = end; }
|
|
466
|
+
}
|
|
467
|
+
const code = content.split('').map((ch, i) => kind[i] === 0 ? ch : (ch === '\n' ? '\n' : ' ')).join('');
|
|
468
|
+
const braces = new Map(), parens = new Map();
|
|
469
|
+
const stack = [], parentheses = [];
|
|
470
|
+
for (let i = 0; i < code.length; i++) {
|
|
471
|
+
if (code[i] === '{') stack.push(i);
|
|
472
|
+
else if (code[i] === '}' && stack.length) braces.set(stack.pop(), i);
|
|
473
|
+
else if (code[i] === '(') parentheses.push(i);
|
|
474
|
+
else if (code[i] === ')' && parentheses.length) parens.set(parentheses.pop(), i);
|
|
475
|
+
}
|
|
476
|
+
const scopes = [], functionScopes = [], loops = [];
|
|
477
|
+
const functions = /\(([^()]*)\)\s*(?::\s*[\w$.[\]<>| ,]+)?\s*(?:=>\s*)?\{/g;
|
|
478
|
+
for (const match of code.matchAll(functions)) {
|
|
479
|
+
const prefix = code.slice(Math.max(0, match.index - 80), match.index);
|
|
480
|
+
if (/\b(?:if|for|while|switch|with)\s*$/.test(prefix)) continue;
|
|
481
|
+
const start = match.index + match[0].length - 1, end = braces.get(start);
|
|
482
|
+
if (end === undefined) continue;
|
|
483
|
+
const scope = { start, end };
|
|
484
|
+
if (!/\bcatch\s*$/.test(prefix)) functionScopes.push(scope);
|
|
485
|
+
const params = match[1];
|
|
486
|
+
if (!lexicalEnvBinding(params)) continue;
|
|
487
|
+
const typed = /(?:^|,)\s*env\s*:\s*(?:Env|[\w$]*Env|[\w$]*Bindings)\s*(?=,|$)/.test(params);
|
|
488
|
+
const fetch = /\bfetch\s*(?::|=)?\s*$/.test(prefix);
|
|
489
|
+
const request = /^\s*[\w$]+\s*:\s*Request\s*,/.test(params);
|
|
490
|
+
scopes.push({ ...scope, binding: typed && (configured || (fetch && request)) });
|
|
491
|
+
}
|
|
492
|
+
for (const match of code.matchAll(/(?:\(\s*env\s*\)|\benv)\s*=>\s*/g)) {
|
|
493
|
+
const start = match.index + match[0].length;
|
|
494
|
+
let end = braces.get(start);
|
|
495
|
+
if (end === undefined) {
|
|
496
|
+
end = start; let depth = 0;
|
|
497
|
+
for (; end < code.length; end++) {
|
|
498
|
+
const ch = code[end];
|
|
499
|
+
if (depth === 0 && /[,;\n)}\]]/.test(ch)) break;
|
|
500
|
+
if ('({['.includes(ch)) depth++;
|
|
501
|
+
else if (')}]'.includes(ch)) depth--;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
scopes.push({ start: start - 1, end, binding: false });
|
|
505
|
+
}
|
|
506
|
+
for (const match of code.matchAll(/\bfor\s*(?:await\s*)?\(/g)) {
|
|
507
|
+
const open = match.index + match[0].length - 1, close = parens.get(open);
|
|
508
|
+
if (close === undefined) continue;
|
|
509
|
+
let body = close + 1;
|
|
510
|
+
while (/\s/.test(code[body] || '') && body < code.length) body++;
|
|
511
|
+
const end = braces.get(body) ?? code.indexOf(';', body);
|
|
512
|
+
if (end >= 0) loops.push({ start: match.index, end, headerEnd: close });
|
|
513
|
+
}
|
|
514
|
+
for (const match of code.matchAll(/\b(const|let|var)\s+(env\b|\{[^;]*?\}|\[[^;]*?\])/g)) {
|
|
515
|
+
if (!lexicalEnvBinding(match[2])) continue;
|
|
516
|
+
let scope;
|
|
517
|
+
if (match[1] === 'var') {
|
|
518
|
+
scope = functionScopes.filter(s => s.start < match.index && match.index < s.end).sort((a, b) => b.start - a.start)[0];
|
|
519
|
+
} else {
|
|
520
|
+
scope = loops.find(s => s.start < match.index && match.index < s.headerEnd);
|
|
521
|
+
if (!scope) scope = [...braces].filter(([start, end]) => start < match.index && match.index < end)
|
|
522
|
+
.sort(([a], [b]) => b - a).map(([start, end]) => ({ start, end }))[0];
|
|
523
|
+
}
|
|
524
|
+
scopes.push({ ...(scope || { start: -1, end: code.length }), binding: false });
|
|
525
|
+
}
|
|
526
|
+
const names = new Set();
|
|
527
|
+
const access = /\benv\s*(?:(?:\?\.|\.)\s*([A-Za-z_$][\w$]*)|(?:\?\.)?\s*\[\s*['"]([A-Za-z_$][\w$]*)['"]\s*\])/g;
|
|
528
|
+
for (const match of content.matchAll(access)) {
|
|
529
|
+
if (kind[match.index] !== 0) continue;
|
|
530
|
+
if (/[\w$]$/.test(code.slice(0, match.index)) || /[.?]$/.test(code.slice(0, match.index).trimEnd())) continue;
|
|
531
|
+
const scope = scopes.filter(s => s.start < match.index && match.index < s.end)
|
|
532
|
+
.sort((a, b) => b.start - a.start || Number(a.binding) - Number(b.binding))[0];
|
|
533
|
+
if (scope?.binding) names.add(match[1] || match[2]);
|
|
534
|
+
}
|
|
535
|
+
return names;
|
|
536
|
+
}
|
|
537
|
+
|
|
319
538
|
export function grepEnvUsage(projectDir, config = {}) {
|
|
320
539
|
const names = new Set();
|
|
321
540
|
const roots = resolveSourceRoots(projectDir, config);
|
|
@@ -359,6 +578,9 @@ export function grepEnvUsage(projectDir, config = {}) {
|
|
|
359
578
|
// the access KEYWORD (process/os/import) — for a real read the keyword is
|
|
360
579
|
// code while only the argument 'X' is a string, so the name is still caught.
|
|
361
580
|
const kind = classifyChars(content, extname(filePath));
|
|
581
|
+
if (['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'].includes(extname(filePath))) {
|
|
582
|
+
for (const name of extractWorkerEnvBindings(content, filePath, workerConfigForFile(projectDir, filePath))) names.add(name);
|
|
583
|
+
}
|
|
362
584
|
// patterns[2] is the import.meta.env one — its matches are Vite-injected
|
|
363
585
|
// when the name is an intrinsic, and must not be reported as user env vars.
|
|
364
586
|
for (let i = 0; i < patterns.length; i++) {
|