@feng0531/dsh-code-review 0.83.0 → 0.84.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/CHANGELOG.md +8 -0
- package/CORE-ALIGNMENT-TASKS.md +1 -1
- package/DEVELOPMENT.md +1 -1
- package/README.md +16 -2
- package/RELEASE-NOTES.md +8 -6
- package/index.mjs +11 -12
- package/package.json +1 -1
- package/src/background-review.mjs +84 -12
- package/src/business-groups.mjs +1 -1
- package/src/class-bindings.mjs +45 -12
- package/src/defect-guidance.mjs +158 -16
- package/src/dsh-model.mjs +107 -15
- package/src/git-change-scope.mjs +39 -0
- package/src/git-history.mjs +202 -56
- package/src/git.mjs +192 -50
- package/src/import-bindings.mjs +74 -65
- package/src/job-progress.mjs +21 -2
- package/src/model-contract.mjs +17 -1
- package/src/primary-input.mjs +87 -26
- package/src/project-report.mjs +22 -5
- package/src/project-review-service.mjs +177 -37
- package/src/project-snapshot.mjs +101 -40
- package/src/prompts.mjs +9 -0
- package/src/protocol-guidance.mjs +13 -1
- package/src/recovery-coverage.mjs +25 -1
- package/src/retrieval-loop.mjs +66 -28
- package/src/review.mjs +39 -33
- package/src/risk-plan.mjs +1 -1
- package/src/service.mjs +140 -6
- package/src/snapshot-reader.mjs +18 -5
- package/src/snapshot.mjs +75 -42
- package/src/svn-capture-budget.mjs +22 -5
- package/src/svn-effective-mergeinfo.mjs +59 -37
- package/src/svn-eol-mapping.mjs +22 -10
- package/src/svn-history-endpoints.mjs +18 -12
- package/src/svn-history-lookup.mjs +34 -26
- package/src/svn-keyword-config.mjs +11 -8
- package/src/svn-keyword-mapping.mjs +67 -36
- package/src/svn-mergeinfo.mjs +34 -22
- package/src/svn-navigation-budget.mjs +15 -4
- package/src/svn-special.mjs +13 -8
- package/src/svn-url.mjs +18 -6
- package/src/svn-working-identity.mjs +22 -8
- package/src/svn-working-scope.mjs +16 -5
- package/src/tool-view.mjs +5 -0
- package/src/verification-loop.mjs +2 -1
package/src/git.mjs
CHANGED
|
@@ -2,57 +2,188 @@ import { isSecretPath } from './secret-path.mjs';
|
|
|
2
2
|
import { realpath } from 'node:fs/promises';
|
|
3
3
|
import { run, checked } from './process.mjs';
|
|
4
4
|
import { hash, decode, readLocal, relativePath } from './content.mjs';
|
|
5
|
+
import { changeScope } from './git-change-scope.mjs';
|
|
5
6
|
|
|
6
7
|
const flags = ['--no-optional-locks', '-c', 'core.fsmonitor=false', '-c', 'core.untrackedCache=false'];
|
|
8
|
+
const STATUS_ARGS = ['status', '--porcelain=v1', '-z', '--untracked-files=all', '--ignore-submodules=none'];
|
|
7
9
|
const git = (cwd, args, options) => checked('git', [...flags, ...args], { ...options, cwd });
|
|
8
10
|
const text = bytes => new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
async function resolveRoot(cwd, options) {
|
|
13
|
+
const top = text(await git(cwd, ['rev-parse', '--show-toplevel'], options)).trim();
|
|
14
|
+
return realpath(top);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** HEAD commit sha, or null only for a genuinely unborn branch. */
|
|
18
|
+
async function resolveHead(root, options) {
|
|
16
19
|
const headResult = await run('git', [...flags, 'rev-parse', '--verify', 'HEAD'], { ...options, cwd: root });
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const records = text(
|
|
20
|
+
const head = headResult.code === 0 ? text(headResult.stdout).trim() : null;
|
|
21
|
+
if (head) return head;
|
|
22
|
+
const branch = text(await git(root, ['symbolic-ref', 'HEAD'], options)).trim();
|
|
23
|
+
const ref = await run('git', [...flags, 'show-ref', '--verify', '--quiet', branch], { ...options, cwd: root });
|
|
24
|
+
if (ref.code !== 1) throw new Error('Cannot resolve Git HEAD');
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function parseChanges(bytes) {
|
|
29
|
+
const records = text(bytes).split('\0');
|
|
27
30
|
const changes = [];
|
|
28
31
|
for (let i = 0; i < records.length; i++) {
|
|
29
32
|
if (!records[i]) continue;
|
|
30
|
-
const rawStatus = records[i].slice(0, 2)
|
|
33
|
+
const rawStatus = records[i].slice(0, 2);
|
|
34
|
+
const name = records[i].slice(3);
|
|
31
35
|
let oldPath;
|
|
32
36
|
if (/[RC]/.test(rawStatus)) oldPath = records[++i];
|
|
33
37
|
changes.push({ path: name, ...(oldPath ? { oldPath } : {}), rawStatus });
|
|
34
38
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
39
|
+
return changes;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Keep only the entries the requested comparison actually observes. */
|
|
43
|
+
function changesInScope(changes, scope) {
|
|
44
|
+
if (scope === 'worktree') return changes;
|
|
45
|
+
if (scope === 'staged') return changes.filter(change => change.rawStatus[0] !== ' ' && change.rawStatus[0] !== '?');
|
|
46
|
+
return changes.filter(change => change.rawStatus[1] !== ' ' || change.rawStatus === '??');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Report only the status column this scope observes so identity follows the compared sides. */
|
|
50
|
+
function scopeStatus(rawStatus, scope) {
|
|
51
|
+
if (scope === 'worktree') return rawStatus;
|
|
52
|
+
if (scope === 'staged') return rawStatus[0] + ' ';
|
|
53
|
+
return rawStatus === '??' ? '??' : ' ' + rawStatus[1];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Serialize one change for the snapshot. An unstaged entry compares index to worktree, so a
|
|
58
|
+
* staged rename's old path is not a side of that comparison and must not reach the model.
|
|
59
|
+
*/
|
|
60
|
+
function scopeEntry(change, scope) {
|
|
61
|
+
const oldPath = scope === 'unstaged' ? undefined : change.oldPath;
|
|
62
|
+
return { path: change.path, ...(oldPath ? { oldPath } : {}), rawStatus: scopeStatus(change.rawStatus, scope) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Resolve only reviewable changed baselines; never enumerate the whole HEAD tree. */
|
|
66
|
+
async function resolveBaselines(root, head, changes, selectedPaths, options, signal) {
|
|
67
|
+
const base = new Map();
|
|
68
|
+
const requested = [...new Set(changes
|
|
69
|
+
.filter(change =>
|
|
70
|
+
!isSecretPath(change.path) &&
|
|
71
|
+
!isSecretPath(change.oldPath) &&
|
|
72
|
+
(!selectedPaths || selectedPaths.includes(change.path)) &&
|
|
73
|
+
(change.rawStatus !== '??' || selectedPaths?.includes(change.path)))
|
|
74
|
+
.map(change => relativePath(change.oldPath ?? change.path)))];
|
|
75
|
+
for (const name of requested) {
|
|
76
|
+
signal?.throwIfAborted();
|
|
77
|
+
const records = text(await git(root, ['--literal-pathspecs', 'ls-tree', '-z', '--full-tree', head, '--', name], options)).split('\0').filter(Boolean);
|
|
78
|
+
for (const record of records) {
|
|
79
|
+
const m = /^(\d{6}) (blob|tree|commit) ([a-f0-9]{40}|[a-f0-9]{64})\t([\s\S]+)$/.exec(record);
|
|
80
|
+
if (!m || m[4] !== name || base.has(name)) throw new Error('Invalid changed baseline lookup');
|
|
81
|
+
base.set(name, { mode: m[1], type: m[2], oid: m[3] });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return base;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Resolve index entries for the selected paths only. Stage entries above zero mean an
|
|
89
|
+
* unresolved conflict; callers block those files instead of reading a partial side.
|
|
90
|
+
*/
|
|
91
|
+
async function resolveIndexEntries(root, names, options) {
|
|
92
|
+
const entries = new Map();
|
|
93
|
+
for (let i = 0; i < names.length; i += 64) {
|
|
94
|
+
const chunk = names.slice(i, i + 64);
|
|
95
|
+
const records = text(await git(root, ['--literal-pathspecs', 'ls-files', '--stage', '-z', '--', ...chunk], options)).split('\0');
|
|
96
|
+
for (const record of records) {
|
|
97
|
+
if (!record) continue;
|
|
98
|
+
const m = /^(\d{6}) ([a-f0-9]{40}|[a-f0-9]{64}) ([0-3])\t([\s\S]+)$/.exec(record);
|
|
99
|
+
if (!m || !chunk.includes(m[4])) throw new Error('Invalid index entry lookup');
|
|
100
|
+
if (entries.has(m[4])) entries.set(m[4], { conflict: true });
|
|
101
|
+
else entries.set(m[4], { mode: m[1], oid: m[2], stage: m[3] });
|
|
48
102
|
}
|
|
49
103
|
}
|
|
104
|
+
return entries;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Stable identity of the index entries a capture depends on; mode and content changes are caught even when status text is unchanged. */
|
|
108
|
+
function indexIdentity(entries, names) {
|
|
109
|
+
return names.map(name => {
|
|
110
|
+
const entry = entries.get(name);
|
|
111
|
+
return name + '\0' + (entry ? (entry.conflict ? 'conflict' : entry.mode + ':' + entry.oid) : '');
|
|
112
|
+
}).join('\n');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function assertReviewableIndexEntry(entry) {
|
|
116
|
+
if (!entry) throw new Error('Missing index baseline');
|
|
117
|
+
if (entry.conflict || entry.stage !== '0') throw new Error('Unresolved conflict');
|
|
118
|
+
if (entry.mode === '120000' || entry.mode === '160000') throw new Error('Submodule or symbolic link baseline');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function readIndexBlob(root, entry, options, maxFileBytes) {
|
|
122
|
+
let bytes;
|
|
123
|
+
try {
|
|
124
|
+
bytes = await git(root, ['cat-file', 'blob', entry.oid], { ...options, maxBytes: maxFileBytes });
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (/output limit exceeded/i.test(error.message)) throw new Error('File size limit exceeded');
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
return decode(bytes);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** A staged deletion has no index entry; every other staged path reads its index blob. */
|
|
133
|
+
async function readStagedRight(root, entry, options, maxFileBytes) {
|
|
134
|
+
if (!entry) return { right: decode(Buffer.alloc(0)), rightExists: false };
|
|
135
|
+
assertReviewableIndexEntry(entry);
|
|
136
|
+
return { right: await readIndexBlob(root, entry, options, maxFileBytes), rightExists: true };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Unstaged left side is the index entry; untracked files have no index baseline. */
|
|
140
|
+
async function readUnstagedLeft(root, change, entries, options, maxFileBytes) {
|
|
141
|
+
if (change.rawStatus === '??') return decode(Buffer.alloc(0));
|
|
142
|
+
const entry = entries.get(change.path);
|
|
143
|
+
assertReviewableIndexEntry(entry);
|
|
144
|
+
return readIndexBlob(root, entry, options, maxFileBytes);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Worktree and staged captures read the same HEAD baseline; unstaged uses the index instead. */
|
|
148
|
+
async function readHeadLeft(root, change, baselines, options, maxFileBytes) {
|
|
149
|
+
const previous = baselines.get(change.oldPath ?? change.path);
|
|
150
|
+
if (previous && (previous.type !== 'blob' || previous.mode === '120000')) throw new Error('Submodule or symbolic link baseline');
|
|
151
|
+
return previous ? decode(await git(root, ['cat-file', 'blob', previous.oid], { ...options, maxBytes: maxFileBytes })) : decode(Buffer.alloc(0));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function readWorktreeRight(root, change, options) {
|
|
155
|
+
try {
|
|
156
|
+
return { right: await readLocal(root, change.path, options), rightExists: true };
|
|
157
|
+
} catch (error) {
|
|
158
|
+
if (error.code === 'ENOENT' && change.rawStatus.includes('D')) return { right: decode(Buffer.alloc(0)), rightExists: false };
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function captureGit(cwd, options = {}) {
|
|
164
|
+
const { signal, selectedPaths, maxFiles = 200, maxFileBytes = 256 * 1024 } = options;
|
|
165
|
+
const scope = changeScope(options.changeScope);
|
|
166
|
+
if (selectedPaths) for (const name of selectedPaths) relativePath(name);
|
|
167
|
+
const root = await resolveRoot(cwd, options);
|
|
168
|
+
const before = await git(root, STATUS_ARGS, options);
|
|
169
|
+
const head = await resolveHead(root, options);
|
|
170
|
+
const changes = changesInScope(parseChanges(before), scope);
|
|
171
|
+
const counted = changes.filter(change => change.rawStatus !== '??' || selectedPaths?.includes(change.path));
|
|
172
|
+
if (counted.length > maxFiles) throw new Error('Change count limit exceeded; narrow repository scope');
|
|
173
|
+
const known = new Set(changes.map(c => c.path));
|
|
174
|
+
if (selectedPaths?.some(p => !known.has(p))) throw new Error('Selected path is not a current change in the selected scope');
|
|
175
|
+
const projectedBefore = scope === 'worktree' ? null : JSON.stringify(changes.map(change => scopeEntry(change, scope)));
|
|
176
|
+
const baselines = scope !== 'unstaged' && head ? await resolveBaselines(root, head, changes, selectedPaths, options, signal) : new Map();
|
|
177
|
+
const indexNames = scope === 'worktree' ? [] : [...new Set(changes.filter(change => change.rawStatus !== '??').map(change => change.path))];
|
|
178
|
+
const indexEntries = scope === 'worktree' ? new Map() : await resolveIndexEntries(root, indexNames, options);
|
|
179
|
+
const indexBefore = indexIdentity(indexEntries, indexNames);
|
|
180
|
+
|
|
50
181
|
const files = [];
|
|
51
182
|
for (const change of changes) {
|
|
52
183
|
signal?.throwIfAborted();
|
|
53
|
-
const item = { ...change, id: hash(change.path), eligibility: 'reviewable', properties: [] };
|
|
184
|
+
const item = { ...scopeEntry(change, scope), id: hash(change.path), eligibility: 'reviewable', properties: [] };
|
|
54
185
|
files.push(item);
|
|
55
|
-
if (isSecretPath(change.path) || isSecretPath(change.oldPath)) { item.eligibility='excluded'; item.reason='Credential path excluded'; continue; }
|
|
186
|
+
if (isSecretPath(change.path) || isSecretPath(change.oldPath)) { item.eligibility = 'excluded'; item.reason = 'Credential path excluded'; continue; }
|
|
56
187
|
if ((selectedPaths && !selectedPaths.includes(change.path)) || (change.rawStatus === '??' && !selectedPaths?.includes(change.path))) {
|
|
57
188
|
item.eligibility = 'excluded'; item.reason = 'Not explicitly selected'; continue;
|
|
58
189
|
}
|
|
@@ -60,34 +191,45 @@ export async function captureGit(cwd, options = {}) {
|
|
|
60
191
|
relativePath(change.path);
|
|
61
192
|
if (change.oldPath) relativePath(change.oldPath);
|
|
62
193
|
if (/U/.test(change.rawStatus) || ['AA', 'DD'].includes(change.rawStatus)) throw new Error('Unresolved conflict');
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
194
|
+
const baselineExists = scope === 'unstaged' ? change.rawStatus !== '??' : baselines.has(change.oldPath ?? change.path);
|
|
195
|
+
item.left = scope === 'unstaged' ? await readUnstagedLeft(root, change, indexEntries, options, maxFileBytes) : await readHeadLeft(root, change, baselines, options, maxFileBytes);
|
|
196
|
+
const right = scope === 'staged'
|
|
197
|
+
? await readStagedRight(root, change.rawStatus[0] === 'D' ? undefined : indexEntries.get(change.path), options, maxFileBytes)
|
|
198
|
+
: await readWorktreeRight(root, change, options);
|
|
199
|
+
item.right = right.right;
|
|
200
|
+
item.rightExists = right.rightExists;
|
|
201
|
+
if (baselineExists && item.rightExists && item.left.hash === item.right.hash && !item.oldPath) {
|
|
202
|
+
item.eligibility = 'excluded'; item.reason = 'No net content change (mode-only changes not analyzed)';
|
|
71
203
|
}
|
|
72
|
-
if (previous && item.rightExists && item.left.hash === item.right.hash && !change.oldPath) { item.eligibility = 'excluded'; item.reason = 'No net content change (mode-only changes not analyzed)'; }
|
|
73
204
|
} catch (error) {
|
|
74
205
|
signal?.throwIfAborted();
|
|
75
206
|
item.eligibility = 'blocked'; item.reason = error.message;
|
|
76
207
|
delete item.left; delete item.right;
|
|
77
208
|
}
|
|
78
209
|
}
|
|
79
|
-
|
|
210
|
+
|
|
211
|
+
const after = await git(root, STATUS_ARGS, options);
|
|
212
|
+
const scopeUnchanged = scope === 'worktree'
|
|
213
|
+
? after.equals(before)
|
|
214
|
+
: JSON.stringify(changesInScope(parseChanges(after), scope).map(change => scopeEntry(change, scope))) === projectedBefore;
|
|
215
|
+
if (!scopeUnchanged) throw new Error('Repository changed during capture');
|
|
80
216
|
const finalHead = await run('git', [...flags, 'rev-parse', '--verify', 'HEAD'], { ...options, cwd: root });
|
|
81
217
|
if ((finalHead.code === 0 ? text(finalHead.stdout).trim() : null) !== head) throw new Error('HEAD changed during capture');
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
218
|
+
if (scope !== 'worktree') {
|
|
219
|
+
const entriesAfter = await resolveIndexEntries(root, indexNames, options);
|
|
220
|
+
if (indexIdentity(entriesAfter, indexNames) !== indexBefore) throw new Error('Index changed during capture');
|
|
221
|
+
}
|
|
222
|
+
if (scope !== 'staged') {
|
|
223
|
+
for (const file of files.filter(f => f.right)) {
|
|
224
|
+
if (file.rightExists) {
|
|
225
|
+
if ((await readLocal(root, file.path, options)).hash !== file.right.hash) throw new Error('Content changed during capture');
|
|
226
|
+
} else {
|
|
227
|
+
try { await readLocal(root, file.path, options); }
|
|
228
|
+
catch (error) { if (error.code === 'ENOENT') continue; throw error; }
|
|
229
|
+
throw new Error('Deleted path reappeared during capture');
|
|
230
|
+
}
|
|
89
231
|
}
|
|
90
232
|
}
|
|
91
|
-
const snapshot = { schemaVersion: 1, vcs: 'git', root, baseline: head, files };
|
|
233
|
+
const snapshot = { schemaVersion: 1, vcs: 'git', root, baseline: head, ...(scope === 'worktree' ? {} : { changeScope: scope }), files };
|
|
92
234
|
return { ...snapshot, id: hash(JSON.stringify(snapshot)) };
|
|
93
235
|
}
|
package/src/import-bindings.mjs
CHANGED
|
@@ -1,81 +1,90 @@
|
|
|
1
1
|
// Conservative direct exported-function hints, never execution/reachability proof.
|
|
2
|
+
function identifierPattern(name, suffix, flags = '') {
|
|
3
|
+
return new RegExp('(^|[^A-Za-z0-9_$])' + name.replaceAll('$', '\\$') + suffix, flags);
|
|
4
|
+
}
|
|
5
|
+
|
|
2
6
|
export function directImportBindings(lines, importLine, calls, targetText) {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
26
|
-
|
|
7
|
+
if (typeof targetText !== 'string' || Buffer.byteLength(targetText) > 256 * 1024 || /[\x60]|\/\*/.test(targetText)) return [];
|
|
8
|
+
const match = /^\s*import\s+\{([^}]+)\}\s+from\s+(['"])[^'"]+\2\s*;?\s*(?:\/\/.*)?$/.exec(lines[importLine - 1]);
|
|
9
|
+
if (!match) return [];
|
|
10
|
+
const bindings = match[1].split(',').map(part => /^\s*([A-Za-z_$][A-Za-z0-9_$]*)(?:\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*))?\s*$/.exec(part));
|
|
11
|
+
if (bindings.some(b => !b)) return [];
|
|
12
|
+
const targets = targetText.split(String.fromCharCode(10));
|
|
13
|
+
const result = [];
|
|
14
|
+
for (const binding of bindings) {
|
|
15
|
+
const imported = binding[1], local = binding[2] ?? imported;
|
|
16
|
+
const token = identifierPattern(local, '([^A-Za-z0-9_$]|$)');
|
|
17
|
+
const positions = calls.filter(c => c.local === local);
|
|
18
|
+
if (!positions.length) continue;
|
|
19
|
+
// Arguments may mutate/escape the alias on the SAME indexed call line.
|
|
20
|
+
// Keep a hint only when the sole local token is the call callee.
|
|
21
|
+
const occurrences = identifierPattern(local, '(?=[^A-Za-z0-9_$]|$)', 'g');
|
|
22
|
+
if (positions.some(call => [...lines[call.line - 1].matchAll(occurrences)].length !== 1)) continue;
|
|
23
|
+
// Every other occurrence may be declaration, parameter, mutation or escape.
|
|
24
|
+
if (lines.some((line, i) => i + 1 !== importLine && !positions.some(c => c.line === i + 1) && token.test(line))) continue;
|
|
25
|
+
const declarations = uniqueApprovedDeclarations(targets, imported);
|
|
26
|
+
if (declarations.length !== 1) continue;
|
|
27
|
+
const exportedToken = identifierPattern(imported, '([^A-Za-z0-9_$]|$)');
|
|
28
|
+
if (targets.some((line, i) => i + 1 !== declarations[0].number && exportedToken.test(line))) continue;
|
|
29
|
+
result.push(...positions.map(call => ({ imported, local, importLine, callLine: call.line, declarationLine: declarations[0].number, declarationKind: declarations[0].kind, confidence: 'conservative-syntax-only' })));
|
|
30
|
+
}
|
|
31
|
+
return result.slice(0, 20);
|
|
27
32
|
}
|
|
28
33
|
|
|
29
34
|
// Only one direct declaration inside already approved text. No extra file reads.
|
|
30
35
|
export function uniqueApprovedDeclarations(lines, imported) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
36
|
+
if (!Array.isArray(lines) || lines.length > 20000 || typeof imported !== 'string' || !/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(imported)) return [];
|
|
37
|
+
const text = lines.join(String.fromCharCode(10));
|
|
38
|
+
if (lines.some(line => typeof line !== 'string') || Buffer.byteLength(text) > 256 * 1024 || text.includes('/*') || text.includes(String.fromCharCode(96)) || text.includes(String.fromCharCode(92, 10)) || text.includes(String.fromCharCode(92, 13, 10))) return [];
|
|
39
|
+
const forms = [
|
|
40
|
+
['function', /^\s*export\s+(?:async\s+)?function\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/],
|
|
41
|
+
['const', /^\s*export\s+(?:const|let)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=/],
|
|
42
|
+
['class', /^\s*export\s+class\s+([A-Za-z_$][A-Za-z0-9_$]*)(?=\s|\{)/],
|
|
43
|
+
];
|
|
44
|
+
const found = [];
|
|
45
|
+
for (let i = 0; i < lines.length; i++) {
|
|
46
|
+
for (const [kind, pattern] of forms) {
|
|
47
|
+
if (pattern.exec(lines[i])?.[1] === imported) found.push({ number: i + 1, kind });
|
|
48
|
+
}
|
|
43
49
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
return found;
|
|
50
|
+
if (found.length !== 1) return [];
|
|
51
|
+
const occurrences = identifierPattern(imported, '(?=[^A-Za-z0-9_$]|$)', 'g');
|
|
52
|
+
if ([...lines[found[0].number - 1].matchAll(occurrences)].length !== 1) return [];
|
|
53
|
+
const token = identifierPattern(imported, '([^A-Za-z0-9_$]|$)');
|
|
54
|
+
if (lines.some((line, i) => i + 1 !== found[0].number && token.test(line))) return [];
|
|
55
|
+
return found;
|
|
51
56
|
}
|
|
52
57
|
|
|
53
58
|
// Named imports only. Missing, duplicate, comment, or reexport names stay unresolved.
|
|
54
59
|
export function namedImportDefinitions(importLine, targetText) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
}
|
|
65
|
-
|
|
60
|
+
if (typeof targetText !== 'string' || Buffer.byteLength(targetText) > 256 * 1024 || /[\x60]|\/\*/.test(targetText)) return [];
|
|
61
|
+
const match = /^\s*import\s+(?:[A-Za-z_$][A-Za-z0-9_$]*\s*,\s*)?\{([^}]+)\}\s+from\s+(['"])[^'"]+\2\s*;?\s*(?:\/\/.*)?$/.exec(importLine);
|
|
62
|
+
if (!match) return [];
|
|
63
|
+
const names = match[1].split(',').map(part => /^\s*([A-Za-z_$][A-Za-z0-9_$]*)(?:\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*))?\s*$/.exec(part));
|
|
64
|
+
if (!names.length || names.some(item => !item) || new Set(names.map(item => item[1])).size !== names.length || new Set(names.map(item => item[2] ?? item[1])).size !== names.length) return [];
|
|
65
|
+
const lines = targetText.split(String.fromCharCode(10));
|
|
66
|
+
const result = [];
|
|
67
|
+
for (const name of names) {
|
|
68
|
+
const found = uniqueApprovedDeclarations(lines, name[1]);
|
|
69
|
+
if (found.length === 1) result.push({ imported: name[1], declarationLine: found[0].number, declarationKind: found[0].kind, confidence: 'approved-unique-syntax-only' });
|
|
70
|
+
}
|
|
71
|
+
return result.slice(0, 20);
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
// Unique default export inside already approved text; anonymous defaults keep their line only.
|
|
69
75
|
export function defaultImportDefinition(importLine, targetText) {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
if (declarations.
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
76
|
+
if (typeof targetText !== 'string' || Buffer.byteLength(targetText) > 256 * 1024 || /[\x60]|\/\*/.test(targetText)) return [];
|
|
77
|
+
if (targetText.includes(String.fromCharCode(92, 10)) || targetText.includes(String.fromCharCode(92, 13, 10))) return [];
|
|
78
|
+
const match = /^\s*import\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?:,\s*\{[^}]*\}\s*)?from\s+(['"])[^'"]+\2\s*;?\s*(?:\/\/.*)?$/.exec(importLine);
|
|
79
|
+
if (!match) return [];
|
|
80
|
+
const lines = targetText.split(String.fromCharCode(10));
|
|
81
|
+
const declarations = [];
|
|
82
|
+
for (let i = 0; i < lines.length; i++) if (/^\s*export\s+default\b/.test(lines[i])) declarations.push(i + 1);
|
|
83
|
+
if (declarations.length !== 1) return [];
|
|
84
|
+
const line = lines[declarations[0] - 1];
|
|
85
|
+
const kind = /^\s*export\s+default\s+(?:function|class)\b/.test(line) ? 'default-declaration'
|
|
86
|
+
: /^\s*export\s+default\s+[^=;]+;\s*(?:\/\/.*)?$/.test(line) ? 'default-expression'
|
|
87
|
+
: undefined;
|
|
88
|
+
if (!kind) return [];
|
|
89
|
+
return [{ imported: 'default', local: match[1], declarationLine: declarations[0], declarationKind: kind, confidence: 'approved-default-syntax-only' }];
|
|
81
90
|
}
|
package/src/job-progress.mjs
CHANGED
|
@@ -1,2 +1,21 @@
|
|
|
1
|
-
import {recoveryCoverage} from './recovery-coverage.mjs';
|
|
2
|
-
|
|
1
|
+
import { recoveryCoverage } from './recovery-coverage.mjs';
|
|
2
|
+
|
|
3
|
+
const NOTICE = 'Percent measures terminal primary-file processing, not successful review or synthesis completion. Unknown capture denominator is null, never invented.';
|
|
4
|
+
|
|
5
|
+
export function jobProgress(record) {
|
|
6
|
+
const r = recoveryCoverage(record);
|
|
7
|
+
const counts = { completed: 0, excluded: 0, blocked: 0, failed: 0, cancelled: 0, pending: 0 };
|
|
8
|
+
for (const f of r.files) counts[f.status] = (counts[f.status] ?? 0) + 1;
|
|
9
|
+
const terminal = !['queued', 'running'].includes(record.status);
|
|
10
|
+
const processed = r.files.filter(f => !['pending'].includes(f.status)).length;
|
|
11
|
+
return {
|
|
12
|
+
stage: record.stage,
|
|
13
|
+
terminal,
|
|
14
|
+
elapsedMs: Date.now() - Date.parse(record.createdAt),
|
|
15
|
+
total: r.manifestSaved ? r.files.length : null,
|
|
16
|
+
counts,
|
|
17
|
+
processed,
|
|
18
|
+
percent: r.manifestSaved && r.files.length ? Math.floor(100 * processed / r.files.length) : null,
|
|
19
|
+
notice: NOTICE,
|
|
20
|
+
};
|
|
21
|
+
}
|
package/src/model-contract.mjs
CHANGED
|
@@ -1,2 +1,18 @@
|
|
|
1
1
|
// Describe only transport shape; never invent evidence or fill missing fields.
|
|
2
|
-
|
|
2
|
+
const VERDICT_CONTRACT = '本阶段必须返回 {"verdicts":[{"candidateId":"c1","verdict":"uncertain","reason":"证据不足原因","evidence":[]}]},candidateId必须来自本次candidates;supported/refuted证据规则仍按原指令。不是findings响应。';
|
|
3
|
+
const GROUPS_CONTRACT = '本阶段返回groups数组,每项含fileIds和reason,文件身份必须来自输入,不是findings响应。';
|
|
4
|
+
const RISK_CONTRACT = '本阶段返回 {"risks":[]} 或原指令要求的风险条目,不是findings。';
|
|
5
|
+
const INTERACTION_CONTRACT = '本阶段返回 {"findings":[],"limitations":[]};发现必须包含原指令全部字段和跨文件interaction.editRefs。无足够跨文件证据时说明limitations,不编造。';
|
|
6
|
+
const FINDINGS_CONTRACT = '本阶段最终返回 {"findings":[],"limitations":[]}。每项finding必须含fileId、severity(critical/high/medium/low)、title、evidence、trigger、impact、suggestion和anchor;fileId原样复制file.id,不用文件名替代。anchor.line用side/start/end/snippet且精确匹配原行;需要更多证据可按检索协议先返回requests。';
|
|
7
|
+
|
|
8
|
+
export function modelContract(input, instructions = '') {
|
|
9
|
+
let p;
|
|
10
|
+
try { p = JSON.parse(input); } catch { return ''; }
|
|
11
|
+
if (!p || typeof p !== 'object' || Array.isArray(p)) return '';
|
|
12
|
+
if (Array.isArray(p.candidates)) return VERDICT_CONTRACT;
|
|
13
|
+
if (/返回\{groups:/.test(instructions)) return GROUPS_CONTRACT;
|
|
14
|
+
if (/仅制定审查计划/.test(instructions)) return RISK_CONTRACT;
|
|
15
|
+
if (p.sourceMode === 'file-interaction') return INTERACTION_CONTRACT;
|
|
16
|
+
if (p.file) return FINDINGS_CONTRACT;
|
|
17
|
+
return '';
|
|
18
|
+
}
|