ucn 5.5.3 → 5.6.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/.claude/skills/ucn/SKILL.md +35 -12
- package/.claude/skills/ucn/references/commands.md +18 -14
- package/.claude/skills/ucn/references/resolution-rules.md +4 -0
- package/.claude/skills/ucn/references/trust-contract.md +4 -4
- package/CHANGELOG.md +474 -0
- package/README.md +69 -29
- package/cli/flags.js +184 -0
- package/cli/index.js +137 -324
- package/core/account.js +44 -87
- package/core/analysis.js +273 -107
- package/core/ast-analysis.js +2 -0
- package/core/bridge.js +14 -0
- package/core/cache.js +101 -16
- package/core/callers.js +352 -70
- package/core/capture.js +331 -0
- package/core/change-kinds.js +888 -0
- package/core/check.js +1117 -139
- package/core/command-contracts.js +7 -6
- package/core/constructed-class.js +1195 -0
- package/core/contract-membership.js +142 -0
- package/core/deadcode.js +362 -84
- package/core/deletion.js +401 -0
- package/core/discovery.js +104 -9
- package/core/entrypoints.js +12 -6
- package/core/evidence-bands.js +68 -0
- package/core/evidence-bands.json +44 -0
- package/core/execute.js +172 -161
- package/core/hot-summary.js +241 -0
- package/core/index-ir.js +3 -1
- package/core/ir.js +5 -1
- package/core/member-signatures.js +789 -0
- package/core/output/analysis-ext.js +20 -10
- package/core/output/analysis.js +98 -60
- package/core/output/check.js +98 -37
- package/core/output/doctor.js +34 -1
- package/core/output/find.js +2 -1
- package/core/output/lines.js +63 -3
- package/core/output/public.js +29 -11
- package/core/output/refactoring.js +32 -0
- package/core/output/reporting.js +15 -2
- package/core/output/search.js +11 -2
- package/core/output/tracing.js +35 -7
- package/core/output-budget.js +124 -57
- package/core/parallel-build.js +3 -3
- package/core/project.js +106 -23
- package/core/provenance-type.js +12 -5
- package/core/public-command.js +35 -9
- package/core/python-modules.js +6 -0
- package/core/reflection.js +151 -23
- package/core/registry.js +623 -72
- package/core/reporting.js +69 -34
- package/core/review-items.js +75 -0
- package/core/search.js +89 -128
- package/core/shared.js +0 -45
- package/core/snapshot.js +350 -0
- package/core/source-universe.js +707 -0
- package/core/test-units.js +3624 -0
- package/core/tracing.js +150 -96
- package/core/verify.js +27 -11
- package/languages/adapter.js +8 -3
- package/languages/c-family.js +12 -0
- package/languages/csharp.js +634 -0
- package/languages/go.js +120 -0
- package/languages/index.js +422 -4
- package/languages/java.js +712 -0
- package/languages/javascript.js +1460 -2
- package/languages/lexical-scope.js +68 -0
- package/languages/python.js +1672 -1
- package/languages/rust.js +91 -0
- package/mcp/server.js +44 -110
- package/package.json +10 -5
package/core/check.js
CHANGED
|
@@ -1,18 +1,82 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* core/check.js
|
|
2
|
+
* core/check.js - Pre-commit check: what can this change break?
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* Every changed declaration gets a verdict from what the change DID, not
|
|
5
|
+
* from the fact that it changed:
|
|
6
|
+
*
|
|
7
|
+
* modified a change that leaves the callable's contract alone (header,
|
|
8
|
+
* kind, inferred return: core/change-kinds.js) is inert: no
|
|
9
|
+
* caller can observe it through the contract. A contract
|
|
10
|
+
* change is checked against its call sites.
|
|
11
|
+
* added a declaration whose name occurs nowhere outside the lines
|
|
12
|
+
* the diff added, and that cannot take part in anything that
|
|
13
|
+
* existed (core/capture.js), is inert.
|
|
14
|
+
* deleted the base revision's declaration is assessed with the rule
|
|
15
|
+
* the dead-code audit uses (core/deletion.js): nothing depends
|
|
16
|
+
* on it, something does, or a named shield says a zero is not
|
|
17
|
+
* evidence.
|
|
18
|
+
*
|
|
19
|
+
* The three verdicts:
|
|
20
|
+
*
|
|
21
|
+
* blocked a break was observed: a confirmed call site that no longer
|
|
22
|
+
* fits the signature, an import of a name its module no
|
|
23
|
+
* longer has, a bare call nothing is left to answer.
|
|
24
|
+
* review the change can reach existing code and the engine cannot
|
|
25
|
+
* settle whether it breaks: the reason is named and the sites
|
|
26
|
+
* are listed.
|
|
27
|
+
* ready nothing that existed before depends on what changed.
|
|
28
|
+
*
|
|
29
|
+
* TRUST is the worst verdict among the items. BLOCKED means a definite
|
|
30
|
+
* break, or that the check itself could not run. It never means "there is
|
|
31
|
+
* something to look at": that is REVIEW_REQUIRED.
|
|
10
32
|
*/
|
|
11
33
|
|
|
12
34
|
'use strict';
|
|
13
35
|
|
|
14
36
|
const { diffImpact, composeAccount, callNotResolvedEntries } = require('./analysis');
|
|
15
|
-
const {
|
|
37
|
+
const { providedNames } = require('./change-kinds');
|
|
38
|
+
const fs = require('fs');
|
|
39
|
+
const path = require('path');
|
|
40
|
+
const { isValueDefinition, isTestPath } = require('./shared');
|
|
41
|
+
const { langTraits } = require('../languages');
|
|
42
|
+
const { assessDeletion, definiteDependentsOf, REASON_TEXT } = require('./deletion');
|
|
43
|
+
const { occurrencesOutsideChange, captureHazards, shapeChangeOf, literalsLackingMember, HAZARD_TEXT } = require('./capture');
|
|
44
|
+
const { reviewItemsOf } = require('./review-items');
|
|
45
|
+
const { createTestModel, linkFiles, commandsFor } = require('./test-units');
|
|
46
|
+
// Reasons a file has no command that are about the file, not the runner.
|
|
47
|
+
const FILE_WOULD_NOT_RUN = new Set(['declared-indirectly', 'collection-switched-off', 'conditional-tests']);
|
|
48
|
+
const { resolveTarget, flaggedPaths } = require('./snapshot');
|
|
49
|
+
const { projectKind, submoduleOf } = require('./deletion');
|
|
50
|
+
|
|
51
|
+
const VERDICT_RANK = { ready: 0, review: 1, blocked: 2 };
|
|
52
|
+
|
|
53
|
+
// What moved in a callable's contract, in words.
|
|
54
|
+
const CHANGE_TEXT = Object.freeze({
|
|
55
|
+
'header': 'its declaration changed (parameters, return type, modifiers or decorators)',
|
|
56
|
+
'callable-kind': 'its body now makes it a different kind of callable',
|
|
57
|
+
'inferred-return-contract': 'its return contract is taken from its body, and what it returns changed',
|
|
58
|
+
'inferred-member-type': 'it assigns to a member of its receiver, and a member\'s type is what the class assigns to it',
|
|
59
|
+
'contract-unreadable': 'its contract before the change could not be read',
|
|
60
|
+
'site-not-checked': 'the sites that no longer fit are under a comment that tells the toolchain not to check them: they are listed, and a build will not show them',
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// What a change did to a module outside its declarations, in words.
|
|
64
|
+
const MODULE_TEXT = Object.freeze({
|
|
65
|
+
'no-longer-provided': 'other files import a name from this module that it no longer provides',
|
|
66
|
+
'import-removed-still-used': 'an import was removed and the file still uses the name it bound',
|
|
67
|
+
'imports-removed': 'a module the file imported or included without binding a name is no longer imported',
|
|
68
|
+
'module-level-code': 'code outside any declaration changed; what runs when the module loads is not modeled',
|
|
69
|
+
'name-in-use': 'a new import binds a name the file already used',
|
|
70
|
+
'downstream-exposure': 'a name left the exports of a library: consumers outside the repository may use it',
|
|
71
|
+
'name-still-used': 'a name the module no longer provides is still written in a file that imports the whole module',
|
|
72
|
+
'star-export-removed': 'the module no longer passes on everything another module exports; which names its importers lose is not written in it',
|
|
73
|
+
'import-rebound': 'a name the file imports now comes from somewhere else: every use of it in the file means something new',
|
|
74
|
+
'module-name-in-use': 'the file is new and imports that existed before can resolve to it: what they meant before, they may not mean now',
|
|
75
|
+
'deleted-still-imported': 'the file was deleted and other files still import it',
|
|
76
|
+
'deleted-still-loaded-unchecked': 'the file was deleted and other files still load it where the toolchain does not look (a call, or an import under a check suppression): it fails when that code runs unless the failure is handled',
|
|
77
|
+
'site-not-checked': 'a name this module no longer provides is still imported, under a comment that tells the toolchain not to check that line',
|
|
78
|
+
'deleted-name-still-imported': 'the file was deleted and other files import a module of its name; a module outside the project may answer',
|
|
79
|
+
});
|
|
16
80
|
|
|
17
81
|
function summarizeAccount(account) {
|
|
18
82
|
if (!account) {
|
|
@@ -33,6 +97,10 @@ function summarizeAccount(account) {
|
|
|
33
97
|
safeToDelete: false,
|
|
34
98
|
unparsedFiles: account.unparsed ? account.unparsed.fileCount || 0 : 0,
|
|
35
99
|
unreadableFiles: Array.isArray(account.unreadableFiles) ? account.unreadableFiles.length : 0,
|
|
100
|
+
// Lines of this name in source UCN reads but does not analyze, and
|
|
101
|
+
// paths nothing could be read from: why an account is incomplete.
|
|
102
|
+
unanalyzedLines: account.unsupported ? account.unsupported.lines || 0 : 0,
|
|
103
|
+
unsearchablePaths: account.opaque ? account.opaque.count || 0 : 0,
|
|
36
104
|
unaccounted: account.unaccounted || 0,
|
|
37
105
|
filtered: account.filtered ? account.filtered.total || 0 : 0,
|
|
38
106
|
beyondTextCallers: account.beyondText ? account.beyondText.count || 0 : 0,
|
|
@@ -40,20 +108,615 @@ function summarizeAccount(account) {
|
|
|
40
108
|
};
|
|
41
109
|
}
|
|
42
110
|
|
|
111
|
+
/** Why an account is not text-complete, in the account's own terms. */
|
|
112
|
+
function incompleteReason(account) {
|
|
113
|
+
if (!account || !account.available) {
|
|
114
|
+
return account && account.reason ? ` (${account.reason})` : '';
|
|
115
|
+
}
|
|
116
|
+
const parts = [];
|
|
117
|
+
if (account.unanalyzedLines > 0) parts.push(`${account.unanalyzedLines} line(s) in unanalyzed source`);
|
|
118
|
+
if (account.unparsedFiles > 0) parts.push(`${account.unparsedFiles} unparsed file(s)`);
|
|
119
|
+
if (account.unreadableFiles > 0) parts.push(`${account.unreadableFiles} unreadable file(s)`);
|
|
120
|
+
if (account.unsearchablePaths > 0) parts.push(`${account.unsearchablePaths} unsearchable path(s)`);
|
|
121
|
+
if (account.unaccounted > 0) parts.push(`${account.unaccounted} unaccounted line(s)`);
|
|
122
|
+
return parts.length ? ` (${parts.join(', ')})` : '';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The indexed definition a diff entry names, or null. */
|
|
126
|
+
function definitionOf(index, entry) {
|
|
127
|
+
const file = entry.relativePath || entry.file || '';
|
|
128
|
+
return (index.symbols.get(entry.name) || []).find(definition =>
|
|
129
|
+
(definition.relativePath === file || definition.file === entry.filePath) &&
|
|
130
|
+
(!entry.startLine || definition.startLine === entry.startLine) &&
|
|
131
|
+
(entry.className == null || (definition.className || null) === (entry.className || null))) || null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Did the type that owns `definition` exist before the change? */
|
|
135
|
+
function ownerExistedBefore(index, definition, addedLinesByFile) {
|
|
136
|
+
if (!definition.className) return false;
|
|
137
|
+
const owners = (index.symbols.get(definition.className) || []).filter(type =>
|
|
138
|
+
type.file === definition.file && !type.className &&
|
|
139
|
+
type.startLine <= definition.startLine && (type.endLine || type.startLine) >= definition.startLine);
|
|
140
|
+
// An owner declared elsewhere (a receiver method, an impl block, an
|
|
141
|
+
// out-of-class definition) is not in the lines this file added.
|
|
142
|
+
if (owners.length === 0) return true;
|
|
143
|
+
const added = addedLinesByFile.get(definition.relativePath);
|
|
144
|
+
return owners.some(owner => !added || !added.has(owner.nameLine || owner.startLine));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Callers of one declaration, both tiers, with the account over them. */
|
|
148
|
+
function callersOf(index, name, definition) {
|
|
149
|
+
const raw = index.findCallers(name, {
|
|
150
|
+
includeMethods: true,
|
|
151
|
+
collectAccount: true,
|
|
152
|
+
targetDefinitions: definition ? [definition] : undefined,
|
|
153
|
+
}) || [];
|
|
154
|
+
const account = composeAccount(index, name, raw);
|
|
155
|
+
return {
|
|
156
|
+
confirmed: raw.filter(caller => caller.tier !== 'unverified'),
|
|
157
|
+
unverified: raw.filter(caller => caller.tier === 'unverified')
|
|
158
|
+
.concat(raw.unverifiedEntries || [])
|
|
159
|
+
.concat(callNotResolvedEntries(index, account)),
|
|
160
|
+
account,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Verdict of an ADDED declaration.
|
|
166
|
+
* @returns {{verdict, reasons: string[], hazards?: string[], outside?: object[]}}
|
|
167
|
+
*/
|
|
168
|
+
function assessAddition(index, entry, definition, addedLinesByFile, account, judge, nonCallable = false) {
|
|
169
|
+
if (!definition) return { verdict: 'review', reasons: ['declaration-not-indexed'] };
|
|
170
|
+
const occurrences = occurrencesOutsideChange(index, entry.name, addedLinesByFile, definition);
|
|
171
|
+
const summary = summarizeAccount(account);
|
|
172
|
+
const reasons = [];
|
|
173
|
+
if (occurrences.outside.length > 0) reasons.push('name-in-use');
|
|
174
|
+
else if (summary.available && (summary.unanalyzedLines > 0 || summary.unsearchablePaths > 0 ||
|
|
175
|
+
summary.unparsedFiles > 0 || summary.unreadableFiles > 0)) reasons.push('novelty-unknown');
|
|
176
|
+
let shield;
|
|
177
|
+
try {
|
|
178
|
+
const assessment = assessDeletion(index, definition);
|
|
179
|
+
shield = assessment.verdict === 'review' ? assessment.reason : null;
|
|
180
|
+
} catch (_) { shield = null; }
|
|
181
|
+
let contract;
|
|
182
|
+
try {
|
|
183
|
+
contract = require('./deadcode').contractFactsOf(index, definition, index.files.get(definition.file)?.language);
|
|
184
|
+
} catch (_) { contract = null; }
|
|
185
|
+
const ownerExisted = ownerExistedBefore(index, definition, addedLinesByFile);
|
|
186
|
+
let shape;
|
|
187
|
+
try { shape = ownerExisted ? shapeChangeOf(index, definition, addedLinesByFile) : null; } catch (_) { shape = 'shape'; }
|
|
188
|
+
const hazards = captureHazards(index, definition, { ownerExisted, contract, shield, nonCallable, shape });
|
|
189
|
+
reasons.push(...hazards);
|
|
190
|
+
// A member every value of the type must now have, and an object
|
|
191
|
+
// literal declared as that type that does not: a break, not a hazard.
|
|
192
|
+
const mustHave = hazards.includes('new-requirement') || shape === 'required';
|
|
193
|
+
const lacking = ownerExisted && mustHave ? literalsLackingMember(index, definition)
|
|
194
|
+
.filter(site => !addedLinesByFile.get(site.file)?.has(site.line) &&
|
|
195
|
+
!judge.suppressed(site, { kind: 'literal' })) : [];
|
|
196
|
+
if (lacking.length > 0) {
|
|
197
|
+
return { verdict: 'blocked', reasons: ['literal-lacks-new-member', ...reasons], outside: lacking };
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
verdict: reasons.length > 0 ? 'review' : 'ready',
|
|
201
|
+
reasons,
|
|
202
|
+
...(occurrences.outside.length > 0 && { outside: occurrences.outside }),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* What the project's own judge looks at. A break is a claim about what the
|
|
208
|
+
* language's toolchain (or, with no checker, its runtime) will reject, so
|
|
209
|
+
* a site it has been told to skip, or an import written as a call it never
|
|
210
|
+
* resolves, is not what blocks: it is listed as what it is.
|
|
211
|
+
*
|
|
212
|
+
* A reader belongs to ONE check: it reads the files as they are when the
|
|
213
|
+
* check runs, and nothing it reads outlives the check (an index is rebuilt
|
|
214
|
+
* between checks).
|
|
215
|
+
*/
|
|
216
|
+
function siteReader(index) {
|
|
217
|
+
let absolute = null;
|
|
218
|
+
const at = relativePath => {
|
|
219
|
+
if (!absolute) {
|
|
220
|
+
absolute = new Map();
|
|
221
|
+
for (const [file, entry] of index.files) absolute.set(entry.relativePath, { file, entry, memo: new Map() });
|
|
222
|
+
}
|
|
223
|
+
return absolute.get(relativePath) || null;
|
|
224
|
+
};
|
|
225
|
+
const rootOf = found => {
|
|
226
|
+
if (!found.memo.has('root')) {
|
|
227
|
+
let root;
|
|
228
|
+
try {
|
|
229
|
+
root = require('./change-kinds').parseRevision(index._readFile(found.file), found.entry.language);
|
|
230
|
+
} catch (_) {
|
|
231
|
+
root = null;
|
|
232
|
+
}
|
|
233
|
+
found.memo.set('root', root);
|
|
234
|
+
}
|
|
235
|
+
return found.memo.get('root');
|
|
236
|
+
};
|
|
237
|
+
return {
|
|
238
|
+
/**
|
|
239
|
+
* The checker has been told not to report this break (trait
|
|
240
|
+
* `checkSuppressedAt`).
|
|
241
|
+
* @param {object} site - { file, line }
|
|
242
|
+
* @param {object} diagnostic - { kind, name?, most? }: what kind
|
|
243
|
+
* of break it is, which decides where and under which rule the
|
|
244
|
+
* checker reports it
|
|
245
|
+
*/
|
|
246
|
+
suppressed(site, diagnostic) {
|
|
247
|
+
const found = site && site.line ? at(site.file) : null;
|
|
248
|
+
const suppressedAt = found && langTraits(found.entry.language)?.checkSuppressedAt;
|
|
249
|
+
if (typeof suppressedAt !== 'function') return false;
|
|
250
|
+
try {
|
|
251
|
+
return !!suppressedAt({
|
|
252
|
+
...diagnostic, line: site.line, lines: index._getFileLines(found.file),
|
|
253
|
+
root: rootOf(found), memo: found.memo,
|
|
254
|
+
});
|
|
255
|
+
} catch (_) {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
/** The import on this line is one the judge resolves (trait `checkedImportAt`). */
|
|
260
|
+
resolvesImport(site) {
|
|
261
|
+
const found = site && site.line ? at(site.file) : null;
|
|
262
|
+
const checkedAt = found && langTraits(found.entry.language)?.checkedImportAt;
|
|
263
|
+
if (typeof checkedAt !== 'function') return true;
|
|
264
|
+
const root = rootOf(found);
|
|
265
|
+
try {
|
|
266
|
+
return root ? !!checkedAt(root, site.line, site.module) : false;
|
|
267
|
+
} catch (_) {
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Split the sites of a break into the ones the judge sees and the ones it
|
|
276
|
+
* was told to skip. `describe(site)` says what kind of break each is.
|
|
277
|
+
*/
|
|
278
|
+
function byJudge(judge, sites, describe) {
|
|
279
|
+
const checked = [];
|
|
280
|
+
const unchecked = [];
|
|
281
|
+
for (const site of sites) {
|
|
282
|
+
if (judge.suppressed(site, describe(site))) unchecked.push({ ...site, unchecked: true });
|
|
283
|
+
else checked.push(site);
|
|
284
|
+
}
|
|
285
|
+
return { checked, unchecked };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// A site of a name that is gone: an import of it, or a bare call of it.
|
|
289
|
+
const goneName = name => site => ({ kind: site.kind === 'call' ? 'undefined-name' : 'import-name', name, module: site.module });
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Verdict of a DELETED declaration, from the base revision's record.
|
|
293
|
+
* @returns {{verdict, reasons: string[], sites?: object[], basis?: string}}
|
|
294
|
+
*/
|
|
295
|
+
function assessRemoval(index, deleted, judge) {
|
|
296
|
+
const declaration = deleted.declaration;
|
|
297
|
+
if (!declaration) return { verdict: 'review', reasons: ['base-declaration-unreadable'] };
|
|
298
|
+
let assessment;
|
|
299
|
+
try {
|
|
300
|
+
assessment = assessDeletion(index, declaration, { fileEntry: deleted.baseFileEntry || null });
|
|
301
|
+
} catch (_) {
|
|
302
|
+
return { verdict: 'review', reasons: ['base-declaration-unreadable'] };
|
|
303
|
+
}
|
|
304
|
+
if (assessment.verdict === 'no-static-dependents') return { verdict: 'ready', reasons: [] };
|
|
305
|
+
const definite = byJudge(judge, definiteDependentsOf(index, declaration), goneName(declaration.name));
|
|
306
|
+
if (definite.checked.length > 0) {
|
|
307
|
+
return {
|
|
308
|
+
verdict: 'blocked', reasons: ['still-referenced'],
|
|
309
|
+
sites: [...definite.checked, ...definite.unchecked], basis: assessment.basis,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
if (definite.unchecked.length > 0) {
|
|
313
|
+
return { verdict: 'review', reasons: ['site-not-checked'], sites: definite.unchecked };
|
|
314
|
+
}
|
|
315
|
+
return {
|
|
316
|
+
verdict: 'review',
|
|
317
|
+
reasons: [assessment.verdict === 'has-static-dependents' ? 'name-still-used' : assessment.reason],
|
|
318
|
+
basis: assessment.basis,
|
|
319
|
+
...(assessment.site && { sites: [assessment.site] }),
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Verdict of what a change did to a module outside its declarations.
|
|
325
|
+
*
|
|
326
|
+
* blocked another file imports a name from the module that the module
|
|
327
|
+
* no longer provides (an export keyword or list entry removed,
|
|
328
|
+
* an import other modules took through it);
|
|
329
|
+
* review an explicit export left a library's surface; an import was
|
|
330
|
+
* dropped whose name the file still uses, or one that binds no
|
|
331
|
+
* name here (a side effect, an inclusion); loose module code
|
|
332
|
+
* changed; a new import binds a name the file already used.
|
|
333
|
+
*
|
|
334
|
+
* @returns {{verdict, reasons: string[], sites: object[]}}
|
|
335
|
+
*/
|
|
336
|
+
function assessModuleChange(index, moved, judge) {
|
|
337
|
+
const sites = [];
|
|
338
|
+
// Files that hold the whole module under a name (`import * as ns`,
|
|
339
|
+
// `import m`): they reach its names as members, not as imports.
|
|
340
|
+
const holders = new Map(); // relative path -> local names of the module
|
|
341
|
+
const bySite = (a, b) => (a.file === b.file ? a.line - b.line : (a.file < b.file ? -1 : 1));
|
|
342
|
+
// What the module still provides: a binding of one of these names, or
|
|
343
|
+
// of a removed one, takes that name. Any other binding of the module
|
|
344
|
+
// is the module itself (`const m = require('./m')`, `import m`).
|
|
345
|
+
const own = index.files.get(moved.filePath);
|
|
346
|
+
const provides = new Set(own?.exports || []);
|
|
347
|
+
if (langTraits(own?.language)?.moduleNamesImportable) {
|
|
348
|
+
for (const symbol of own?.symbols || []) if (!symbol.className) provides.add(symbol.name);
|
|
349
|
+
}
|
|
350
|
+
const named = binding => moved.removedNames.includes(binding.name) || provides.has(binding.name);
|
|
351
|
+
for (const entry of index.files.values()) {
|
|
352
|
+
const resolved = entry.moduleResolved || {};
|
|
353
|
+
for (const binding of entry.importBindings || []) {
|
|
354
|
+
if (binding.dynamic || resolved[binding.module] !== moved.relativePath) continue;
|
|
355
|
+
if (binding.kind === 'namespace' ||
|
|
356
|
+
((binding.kind === 'import' || binding.kind === 'require') && !named(binding))) {
|
|
357
|
+
if (!holders.has(entry.relativePath)) holders.set(entry.relativePath, new Set());
|
|
358
|
+
holders.get(entry.relativePath).add(binding.alias || binding.name);
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
const taken = binding.kind === 'default' ? 'default' : binding.name;
|
|
362
|
+
if (!moved.removedNames.includes(taken)) continue;
|
|
363
|
+
if (taken !== 'default' && submoduleOf(resolved, binding.module, taken)) continue;
|
|
364
|
+
sites.push(Object.defineProperty({ file: entry.relativePath, line: binding.line || 0, kind: `import ${taken}` },
|
|
365
|
+
'module', { value: binding.module, enumerable: false }));
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
sites.sort(bySite);
|
|
369
|
+
const importing = byJudge(judge, sites, site =>
|
|
370
|
+
({ kind: 'import-name', name: site.kind.replace(/^import /, ''), module: site.module }));
|
|
371
|
+
if (importing.checked.length > 0) {
|
|
372
|
+
return { verdict: 'blocked', reasons: ['no-longer-provided'], sites: [...importing.checked, ...importing.unchecked] };
|
|
373
|
+
}
|
|
374
|
+
const reasons = [];
|
|
375
|
+
const stillUsed = [];
|
|
376
|
+
if (importing.unchecked.length > 0) reasons.push('site-not-checked');
|
|
377
|
+
// A removed name still written in a file that holds the module: it may
|
|
378
|
+
// be the member that file reaches through it.
|
|
379
|
+
for (const name of moved.removedNames) {
|
|
380
|
+
if (name === 'default' || holders.size === 0) continue;
|
|
381
|
+
for (const row of index.usages(name, { includeTests: true, codeOnly: true }) || []) {
|
|
382
|
+
const file = row.relativePath || row.file;
|
|
383
|
+
if (!holders.has(file) || row.isDefinition) continue;
|
|
384
|
+
stillUsed.push({ file, line: row.line, kind: `uses ${name}` });
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (stillUsed.length > 0) reasons.push('name-still-used');
|
|
388
|
+
const held = stillUsed.length;
|
|
389
|
+
for (const name of moved.removedBindings) {
|
|
390
|
+
for (const row of index.usages(name, { includeTests: true, codeOnly: true }) || []) {
|
|
391
|
+
if ((row.relativePath || row.file) !== moved.relativePath) continue;
|
|
392
|
+
if (row.isDefinition || row.usageType === 'import' || row.usageType === 'definition') continue;
|
|
393
|
+
stillUsed.push({ file: moved.relativePath, line: row.line, kind: `uses ${name}` });
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
if (stillUsed.length > held) reasons.push('import-removed-still-used');
|
|
397
|
+
// A module that is no longer imported and bound no name was there
|
|
398
|
+
// for what importing it does (a side effect, an inclusion).
|
|
399
|
+
if ((moved.removedBareImports || []).length > 0) reasons.push('imports-removed');
|
|
400
|
+
if (moved.removedExports.length > 0 && projectKind(index) === 'library') reasons.push('downstream-exposure');
|
|
401
|
+
if ((moved.reboundBindings || []).length > 0) reasons.push('import-rebound');
|
|
402
|
+
if (moved.starExportRemoved) reasons.push('star-export-removed');
|
|
403
|
+
// Loose code in a test file is the test: nothing imports it for
|
|
404
|
+
// what it does when it loads.
|
|
405
|
+
if (moved.codeChanged && !isTestPath(moved.relativePath)) reasons.push('module-level-code');
|
|
406
|
+
if ((moved.shadowed || []).length > 0) reasons.push('name-in-use');
|
|
407
|
+
return {
|
|
408
|
+
verdict: reasons.length > 0 ? 'review' : 'ready', reasons,
|
|
409
|
+
sites: [...importing.unchecked, ...stillUsed].sort(bySite),
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const SOURCE_EXTENSION = /\.[A-Za-z0-9+]+$/;
|
|
414
|
+
// A file that stands for its directory when imported.
|
|
415
|
+
const PACKAGE_FILE = /(?:^|\/)(?:__init__|index|mod)$/;
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Imports that named a file the change deleted, read from how each import
|
|
419
|
+
* is written (nothing resolves to a file that is gone).
|
|
420
|
+
*
|
|
421
|
+
* definite a specifier that can only have meant the deleted file: a
|
|
422
|
+
* relative one that lands exactly on it (`./gone`,
|
|
423
|
+
* `from .gone import x`), or a name inside a package of the
|
|
424
|
+
* project that still resolves (`import pkg.gone`,
|
|
425
|
+
* `from pkg import gone` where the package's own file does not
|
|
426
|
+
* supply `gone` itself). Never while something else on disk
|
|
427
|
+
* can answer the same specifier: the name with another
|
|
428
|
+
* extension (`gone.d.ts`, `gone.pyi`), a directory of that
|
|
429
|
+
* name, or the directory itself where a package needs no
|
|
430
|
+
* package file (trait `packageFileOptional`);
|
|
431
|
+
* possible an absolute module name whose path form ends at the deleted
|
|
432
|
+
* file (`import gone`, `#include "gone.h"`): something outside
|
|
433
|
+
* the project may carry the same name.
|
|
434
|
+
*
|
|
435
|
+
* A definite import the project's judge does not look at (a call it never
|
|
436
|
+
* resolves, a line under a check suppression: traits `checkedImportAt`,
|
|
437
|
+
* `checkSuppressedAt`) is `unchecked`: it fails when that code runs, and
|
|
438
|
+
* no build will say so.
|
|
439
|
+
*
|
|
440
|
+
* @returns {{definite: object[], unchecked: object[], possible: object[]}}
|
|
441
|
+
* sites (file, line)
|
|
442
|
+
*/
|
|
443
|
+
function importersOfDeletedFile(index, deleted, judge) {
|
|
444
|
+
const posix = path.posix;
|
|
445
|
+
const stem = deleted.replace(SOURCE_EXTENSION, '');
|
|
446
|
+
// A package's own file stands for its directory.
|
|
447
|
+
const asDirectory = PACKAGE_FILE.test(stem) ? posix.dirname(stem) : null;
|
|
448
|
+
const lands = candidate => candidate === stem || candidate === deleted || (asDirectory !== null && candidate === asDirectory);
|
|
449
|
+
// What is left on disk beside the deleted file: the same name with
|
|
450
|
+
// another extension or as a directory, and the directory it stood for.
|
|
451
|
+
let twin = false;
|
|
452
|
+
let directoryLeft = false;
|
|
453
|
+
try {
|
|
454
|
+
const name = posix.basename(stem);
|
|
455
|
+
const beside = fs.readdirSync(path.join(index.root, posix.dirname(deleted)));
|
|
456
|
+
twin = beside.some(other => other === name || other.startsWith(`${name}.`));
|
|
457
|
+
directoryLeft = asDirectory !== null;
|
|
458
|
+
} catch (_) {
|
|
459
|
+
// The directory went with the file.
|
|
460
|
+
}
|
|
461
|
+
// Is a landing on the deleted file one nothing else can answer?
|
|
462
|
+
const final = (candidate, traits) => !twin &&
|
|
463
|
+
!(directoryLeft && candidate === asDirectory && traits.packageFileOptional);
|
|
464
|
+
// The directory a package's own file stands for, when `file` is one.
|
|
465
|
+
const packageDirectory = file => {
|
|
466
|
+
const own = String(file).replace(SOURCE_EXTENSION, '');
|
|
467
|
+
return PACKAGE_FILE.test(own) ? posix.dirname(own) : null;
|
|
468
|
+
};
|
|
469
|
+
let byRelativePath = null;
|
|
470
|
+
const entryAt = relativePath => {
|
|
471
|
+
if (!byRelativePath) {
|
|
472
|
+
byRelativePath = new Map();
|
|
473
|
+
for (const fileEntry of index.files.values()) byRelativePath.set(fileEntry.relativePath, fileEntry);
|
|
474
|
+
}
|
|
475
|
+
return byRelativePath.get(relativePath) || null;
|
|
476
|
+
};
|
|
477
|
+
// Can the package's own file hand out `name` without a submodule of
|
|
478
|
+
// that name? 'named': it declares or imports it. 'open': it takes
|
|
479
|
+
// everything another module has, computes its names (trait
|
|
480
|
+
// `moduleSupplyHooks`), or cannot be read. 'none': the name was the
|
|
481
|
+
// submodule.
|
|
482
|
+
const supplyOf = (packageFile, name, traits) => {
|
|
483
|
+
const own = packageFile ? entryAt(packageFile) : null;
|
|
484
|
+
if (!own) return packageFile ? 'open' : 'none';
|
|
485
|
+
const provided = providedNames(own, traits);
|
|
486
|
+
if (provided.names.has(name)) return 'named';
|
|
487
|
+
return provided.open ||
|
|
488
|
+
(traits.moduleSupplyHooks || []).some(hook => provided.names.has(hook)) ||
|
|
489
|
+
(own.importDetails || []).some(detail => (detail.names || []).includes('*')) ? 'open' : 'none';
|
|
490
|
+
};
|
|
491
|
+
// A from-import of names that land on the deleted file.
|
|
492
|
+
// What the checker would report each definite site for: a module that
|
|
493
|
+
// cannot be found, or a name its module does not have.
|
|
494
|
+
const missing = new Map();
|
|
495
|
+
const place = (site, named, packageFile, traits) => {
|
|
496
|
+
const supplies = named.map(name => supplyOf(packageFile, name, traits));
|
|
497
|
+
if (supplies.includes('none')) {
|
|
498
|
+
missing.set(site, { kind: 'import-name', name: named[supplies.indexOf('none')], module: site.module });
|
|
499
|
+
(twin ? possible : definite).push(site);
|
|
500
|
+
} else if (supplies.includes('open')) possible.push(site);
|
|
501
|
+
};
|
|
502
|
+
const definite = [];
|
|
503
|
+
const possible = [];
|
|
504
|
+
for (const entry of index.files.values()) {
|
|
505
|
+
const resolved = entry.moduleResolved || {};
|
|
506
|
+
const from = posix.dirname(entry.relativePath);
|
|
507
|
+
const traits = langTraits(entry.language) || {};
|
|
508
|
+
for (const detail of entry.importDetails || []) {
|
|
509
|
+
const spelled = String(detail.module || '');
|
|
510
|
+
if (!spelled) continue;
|
|
511
|
+
const site = Object.defineProperty({ file: entry.relativePath, line: detail.line || 0, kind: 'import' },
|
|
512
|
+
'module', { value: spelled, enumerable: false });
|
|
513
|
+
if (resolved[spelled]) {
|
|
514
|
+
// The module is still there. Where a from-import can bind a
|
|
515
|
+
// submodule file (trait `submoduleImports`), a name the
|
|
516
|
+
// package's own file does not supply was that file.
|
|
517
|
+
const directory = traits.submoduleImports ? packageDirectory(resolved[spelled]) : null;
|
|
518
|
+
if (directory === null) continue;
|
|
519
|
+
const named = (detail.names || []).filter(name => name !== '*' &&
|
|
520
|
+
!submoduleOf(resolved, spelled, name) && lands(posix.join(directory, name)));
|
|
521
|
+
place(site, named, resolved[spelled], traits);
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
if (/^\.{1,2}\//.test(spelled)) {
|
|
525
|
+
// A path relative to the importing file.
|
|
526
|
+
const target = posix.normalize(posix.join(from, spelled)).replace(SOURCE_EXTENSION, '');
|
|
527
|
+
if (lands(target)) (final(target, traits) ? definite : possible).push(site);
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
const dots = /^\.+/.exec(spelled);
|
|
531
|
+
if (dots) {
|
|
532
|
+
// A module relative to the importing package: one dot is
|
|
533
|
+
// the package, each further dot its parent.
|
|
534
|
+
let base = from;
|
|
535
|
+
for (let up = 1; up < dots[0].length; up++) base = posix.dirname(base);
|
|
536
|
+
const rest = spelled.slice(dots[0].length).split('.').filter(Boolean);
|
|
537
|
+
if (rest.length > 0) {
|
|
538
|
+
const target = posix.normalize(posix.join(base, ...rest));
|
|
539
|
+
if (lands(target)) (final(target, traits) ? definite : possible).push(site);
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
// `from . import name`: the package's own file may be what
|
|
543
|
+
// supplies the name.
|
|
544
|
+
const directory = posix.normalize(base);
|
|
545
|
+
let packageFile = null;
|
|
546
|
+
for (const other of index.files.values()) {
|
|
547
|
+
if (other.language === entry.language && packageDirectory(other.relativePath) === directory) {
|
|
548
|
+
packageFile = other.relativePath;
|
|
549
|
+
break;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
const named = (detail.names || []).filter(name => lands(posix.normalize(posix.join(base, name))));
|
|
553
|
+
place(site, named, packageFile, traits);
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
// A dotted name under a package of the project that still
|
|
557
|
+
// resolves: the rest of the path is inside that package.
|
|
558
|
+
const parts = spelled.split('.');
|
|
559
|
+
let inside = false;
|
|
560
|
+
for (let length = parts.length - 1; length > 0 && !inside; length--) {
|
|
561
|
+
const prefix = resolved[parts.slice(0, length).join('.')];
|
|
562
|
+
const directory = prefix ? packageDirectory(prefix) : null;
|
|
563
|
+
if (directory !== null && lands(posix.join(directory, ...parts.slice(length)))) inside = true;
|
|
564
|
+
}
|
|
565
|
+
if (inside) { (twin ? possible : definite).push(site); continue; }
|
|
566
|
+
// An absolute name: its path form, anchored at any directory.
|
|
567
|
+
const forms = [spelled.replace(SOURCE_EXTENSION, ''), spelled.split('.').join('/')];
|
|
568
|
+
for (const name of detail.names || []) forms.push(`${spelled.split('.').join('/')}/${name}`);
|
|
569
|
+
if (forms.some(form => stem === form || stem.endsWith(`/${form}`) ||
|
|
570
|
+
(asDirectory !== null && (asDirectory === form || asDirectory.endsWith(`/${form}`))))) possible.push(site);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
const bySite = (a, b) => (a.file === b.file ? a.line - b.line : (a.file < b.file ? -1 : 1));
|
|
574
|
+
const seen = [];
|
|
575
|
+
const unchecked = [];
|
|
576
|
+
for (const site of definite.sort(bySite)) {
|
|
577
|
+
if (judge.resolvesImport(site) &&
|
|
578
|
+
!judge.suppressed(site, missing.get(site) || { kind: 'import-module', module: site.module })) seen.push(site);
|
|
579
|
+
else unchecked.push({ ...site, unchecked: true });
|
|
580
|
+
}
|
|
581
|
+
return { definite: seen, unchecked, possible: possible.sort(bySite) };
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** The path forms an import's specifier can mean, read as it is written. */
|
|
585
|
+
function specifierForms(entry, detail) {
|
|
586
|
+
const posix = path.posix;
|
|
587
|
+
const spelled = String(detail.module || '');
|
|
588
|
+
const from = posix.dirname(entry.relativePath);
|
|
589
|
+
// exact: relative to the importing file or its package. loose: a name
|
|
590
|
+
// that is looked up from some root.
|
|
591
|
+
const exact = [];
|
|
592
|
+
const loose = [];
|
|
593
|
+
if (!spelled) return { exact, loose };
|
|
594
|
+
if (/^\.{1,2}\//.test(spelled)) {
|
|
595
|
+
exact.push(posix.normalize(posix.join(from, spelled)).replace(SOURCE_EXTENSION, ''));
|
|
596
|
+
return { exact, loose };
|
|
597
|
+
}
|
|
598
|
+
const names = (detail.names || []).filter(name => name !== '*');
|
|
599
|
+
const dots = /^\.+/.exec(spelled);
|
|
600
|
+
if (dots) {
|
|
601
|
+
let base = from;
|
|
602
|
+
for (let up = 1; up < dots[0].length; up++) base = posix.dirname(base);
|
|
603
|
+
const rest = spelled.slice(dots[0].length).split('.').filter(Boolean);
|
|
604
|
+
const module = posix.normalize(posix.join(base, ...rest));
|
|
605
|
+
if (rest.length > 0) exact.push(module);
|
|
606
|
+
for (const name of names) exact.push(posix.normalize(posix.join(module, name)));
|
|
607
|
+
return { exact, loose };
|
|
608
|
+
}
|
|
609
|
+
const slashed = spelled.split('.').join('/');
|
|
610
|
+
loose.push(spelled.replace(SOURCE_EXTENSION, ''), slashed);
|
|
611
|
+
for (const name of names) loose.push(`${slashed}/${name}`);
|
|
612
|
+
return { exact, loose };
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Imports that existed before the change and that can mean a file the
|
|
617
|
+
* change ADDED: the index now resolves them to it; or their specifier,
|
|
618
|
+
* relative to where it is written, lands on it; or it names a module of
|
|
619
|
+
* the project that the new file now stands beside under the same name
|
|
620
|
+
* (`lib.py` and a new `lib/` package, `lib/index.ts` and a new `lib.ts`).
|
|
621
|
+
* Which of two the language picks is its resolver's order; that the
|
|
622
|
+
* import meant something else before is certain.
|
|
623
|
+
*
|
|
624
|
+
* @returns {object[]} sites (file, line), sorted
|
|
625
|
+
*/
|
|
626
|
+
function importsThatCanMean(index, added, addedLinesByFile) {
|
|
627
|
+
const posix = path.posix;
|
|
628
|
+
const stem = added.replace(SOURCE_EXTENSION, '');
|
|
629
|
+
const asDirectory = PACKAGE_FILE.test(stem) ? posix.dirname(stem) : null;
|
|
630
|
+
const lands = form => form === stem || form === asDirectory;
|
|
631
|
+
const landsLoosely = form => lands(form) || stem.endsWith(`/${form}`) ||
|
|
632
|
+
(asDirectory !== null && asDirectory.endsWith(`/${form}`));
|
|
633
|
+
const sites = [];
|
|
634
|
+
for (const entry of index.files.values()) {
|
|
635
|
+
if (entry.relativePath === added) continue;
|
|
636
|
+
const resolved = entry.moduleResolved || {};
|
|
637
|
+
const addedLines = addedLinesByFile.get(entry.relativePath);
|
|
638
|
+
for (const detail of entry.importDetails || []) {
|
|
639
|
+
if (!detail.line || (addedLines && addedLines.has(detail.line))) continue;
|
|
640
|
+
const spelled = String(detail.module || '');
|
|
641
|
+
const composed = (detail.names || []).map(name => (spelled.endsWith('.') ? spelled + name : `${spelled}.${name}`));
|
|
642
|
+
const forms = specifierForms(entry, detail);
|
|
643
|
+
const taken = resolved[spelled] === added || composed.some(name => resolved[name] === added);
|
|
644
|
+
const beside = !!resolved[spelled] && resolved[spelled] !== added && forms.loose.some(landsLoosely);
|
|
645
|
+
if (taken || beside || forms.exact.some(lands)) {
|
|
646
|
+
sites.push({ file: entry.relativePath, line: detail.line, kind: 'import' });
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
return sites.sort((a, b) => (a.file === b.file ? a.line - b.line : (a.file < b.file ? -1 : 1)));
|
|
651
|
+
}
|
|
652
|
+
|
|
43
653
|
/**
|
|
44
654
|
* Run the pre-commit check.
|
|
45
655
|
*
|
|
46
656
|
* @param {object} index - ProjectIndex
|
|
47
|
-
* @param {object} options - { base, staged, file, limit }
|
|
657
|
+
* @param {object} options - { base, staged, file, limit, strict }
|
|
658
|
+
* strict: REVIEW_REQUIRED does not pass either (`trust.passes`).
|
|
48
659
|
* @returns {object}
|
|
49
660
|
*/
|
|
50
|
-
function check(
|
|
661
|
+
function check(live, options = {}) {
|
|
662
|
+
// A staged check describes the git index. Everything below reads the
|
|
663
|
+
// tree the diff's new side lives in (core/snapshot.js).
|
|
664
|
+
let target;
|
|
665
|
+
try {
|
|
666
|
+
target = resolveTarget(live, { staged: !!options.staged, force: !!options.forceStagedSnapshot });
|
|
667
|
+
} catch (e) {
|
|
668
|
+
if (e && e.code === 'staged-snapshot-unavailable') {
|
|
669
|
+
// Outside a repository there is no index to describe: the
|
|
670
|
+
// ordinary "not a git repository" report is the truthful one.
|
|
671
|
+
if (e.reason === 'not-a-repository') {
|
|
672
|
+
try { diffImpact(live, { base: options.base || 'HEAD', staged: true, file: options.file }); } catch (inner) {
|
|
673
|
+
const message = inner && inner.message ? inner.message : 'diff failed';
|
|
674
|
+
return {
|
|
675
|
+
base: options.base || 'HEAD', staged: true, ok: false,
|
|
676
|
+
status: /not a git repositor/i.test(message) ? 'not-a-repo' : 'diff-failed',
|
|
677
|
+
error: message,
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
return {
|
|
682
|
+
base: options.base || 'HEAD', staged: true, ok: false,
|
|
683
|
+
status: 'staged-snapshot-unavailable',
|
|
684
|
+
error: `the staged tree could not be exported and indexed (${e.message}); nothing was checked`,
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
throw e;
|
|
688
|
+
}
|
|
689
|
+
try {
|
|
690
|
+
const result = checkTarget(target.index, options, target.repoRoot);
|
|
691
|
+
const notes = [...target.notes];
|
|
692
|
+
// A worktree check reads the worktree, but which files CHANGED is
|
|
693
|
+
// git's answer, and git does not look at a path it was told to
|
|
694
|
+
// assume unchanged or to skip.
|
|
695
|
+
if (!options.staged && result && result.ok !== false) {
|
|
696
|
+
let flagged;
|
|
697
|
+
try { flagged = flaggedPaths(live.root); } catch (_) { flagged = []; }
|
|
698
|
+
if (flagged.length > 0) {
|
|
699
|
+
notes.push(`${flagged.length} tracked path(s) are marked assume-unchanged or skip-worktree ` +
|
|
700
|
+
`(${flagged[0].path}${flagged.length > 1 ? ', ...' : ''}): git does not report their changes, so they are not checked`);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
if (notes.length > 0 && result && result.ok !== false) result.notes = notes;
|
|
704
|
+
return result;
|
|
705
|
+
} finally {
|
|
706
|
+
target.dispose();
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function checkTarget(index, options, repoRoot) {
|
|
711
|
+
// What this check reads about each site: nothing outlives it.
|
|
712
|
+
const judge = siteReader(index);
|
|
51
713
|
let dr;
|
|
52
714
|
try {
|
|
53
715
|
dr = diffImpact(index, {
|
|
54
716
|
base: options.base || 'HEAD',
|
|
55
717
|
staged: !!options.staged,
|
|
56
718
|
file: options.file,
|
|
719
|
+
repoRoot,
|
|
57
720
|
});
|
|
58
721
|
} catch (e) {
|
|
59
722
|
// The gate could not run at all. This must stay distinguishable from a
|
|
@@ -79,6 +742,7 @@ function check(index, options = {}) {
|
|
|
79
742
|
...(dr?.newSymbols || []).map(s => ({ ...s, _kind: 'added', _nonCallable: true })),
|
|
80
743
|
...(dr?.deletedSymbols || []).map(s => ({ ...s, _kind: 'deleted', _nonCallable: true })),
|
|
81
744
|
];
|
|
745
|
+
const addedLinesByFile = dr?.addedLinesByFile || new Map();
|
|
82
746
|
const pathCounts = {
|
|
83
747
|
changedPaths: dr?.changedPaths || 0,
|
|
84
748
|
nonSourcePaths: dr?.nonSourcePaths || 0,
|
|
@@ -91,7 +755,72 @@ function check(index, options = {}) {
|
|
|
91
755
|
...declarations,
|
|
92
756
|
];
|
|
93
757
|
|
|
94
|
-
|
|
758
|
+
// Outside the declarations: what each changed module still provides to
|
|
759
|
+
// its importers, which imports it dropped, what loose code it runs.
|
|
760
|
+
const moduleItems = [];
|
|
761
|
+
for (const moved of dr?.moduleChanges || []) {
|
|
762
|
+
const outcome = assessModuleChange(index, moved, judge);
|
|
763
|
+
if (outcome.verdict === 'ready') continue;
|
|
764
|
+
moduleItems.push({
|
|
765
|
+
name: '(module)',
|
|
766
|
+
file: moved.relativePath,
|
|
767
|
+
line: 1,
|
|
768
|
+
kind: 'module',
|
|
769
|
+
callerCount: 0,
|
|
770
|
+
unverifiedCallerCount: 0,
|
|
771
|
+
signatureMismatches: 0,
|
|
772
|
+
verdict: outcome.verdict,
|
|
773
|
+
reasons: outcome.reasons,
|
|
774
|
+
...(outcome.sites.length > 0 && { sites: outcome.sites }),
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// A file that is gone, and the imports that named it.
|
|
779
|
+
for (const relativePath of dr?.deletedFiles || []) {
|
|
780
|
+
const importers = importersOfDeletedFile(index, relativePath, judge);
|
|
781
|
+
if (importers.definite.length + importers.unchecked.length + importers.possible.length === 0) continue;
|
|
782
|
+
const broken = importers.definite.length > 0;
|
|
783
|
+
const reasons = [];
|
|
784
|
+
if (broken) reasons.push('deleted-still-imported');
|
|
785
|
+
else {
|
|
786
|
+
if (importers.unchecked.length > 0) reasons.push('deleted-still-loaded-unchecked');
|
|
787
|
+
if (importers.possible.length > 0) reasons.push('deleted-name-still-imported');
|
|
788
|
+
}
|
|
789
|
+
moduleItems.push({
|
|
790
|
+
name: '(module)',
|
|
791
|
+
file: relativePath,
|
|
792
|
+
line: 1,
|
|
793
|
+
kind: 'module',
|
|
794
|
+
callerCount: 0,
|
|
795
|
+
unverifiedCallerCount: 0,
|
|
796
|
+
signatureMismatches: 0,
|
|
797
|
+
verdict: broken ? 'blocked' : 'review',
|
|
798
|
+
reasons,
|
|
799
|
+
sites: broken ? [...importers.definite, ...importers.unchecked]
|
|
800
|
+
: [...importers.unchecked, ...importers.possible].sort((a, b) =>
|
|
801
|
+
(a.file === b.file ? a.line - b.line : (a.file < b.file ? -1 : 1))),
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// A file that is new, and the imports that could already mean it.
|
|
806
|
+
for (const relativePath of dr?.addedFiles || []) {
|
|
807
|
+
const sites = importsThatCanMean(index, relativePath, addedLinesByFile);
|
|
808
|
+
if (sites.length === 0) continue;
|
|
809
|
+
moduleItems.push({
|
|
810
|
+
name: '(module)',
|
|
811
|
+
file: relativePath,
|
|
812
|
+
line: 1,
|
|
813
|
+
kind: 'module',
|
|
814
|
+
callerCount: 0,
|
|
815
|
+
unverifiedCallerCount: 0,
|
|
816
|
+
signatureMismatches: 0,
|
|
817
|
+
verdict: 'review',
|
|
818
|
+
reasons: ['module-name-in-use'],
|
|
819
|
+
sites,
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
if (!dr || (allChanged.length === 0 && deleted.length === 0 && moduleItems.length === 0)) {
|
|
95
824
|
// fix #283: say what the diff actually contained. "no changes
|
|
96
825
|
// detected" with three changed files reads as a false negative in a
|
|
97
826
|
// pre-commit hook — the truth is the changes are outside what the
|
|
@@ -120,7 +849,6 @@ function check(index, options = {}) {
|
|
|
120
849
|
|
|
121
850
|
const items = [];
|
|
122
851
|
|
|
123
|
-
// For each changed function, run verify and gather caller summary
|
|
124
852
|
for (const fn of changed) {
|
|
125
853
|
const filePath = fn.relativePath || fn.file || '';
|
|
126
854
|
if (fn._nonCallable) {
|
|
@@ -130,7 +858,7 @@ function check(index, options = {}) {
|
|
|
130
858
|
// change is reviewed there, as a function body change is through
|
|
131
859
|
// its callers. A type's shape and inheritance are not checked.
|
|
132
860
|
const valueDeclaration = isValueDefinition(fn);
|
|
133
|
-
|
|
861
|
+
const item = {
|
|
134
862
|
name: fn.name, file: filePath, line: fn.startLine, kind: fn._kind,
|
|
135
863
|
symbolType: fn.type, requiresToolchainValidation: true,
|
|
136
864
|
...(valueDeclaration && { valueDeclaration: true }),
|
|
@@ -143,74 +871,152 @@ function check(index, options = {}) {
|
|
|
143
871
|
unverifiedDependencyCount: (dependency?.unverifiedCount || 0) + (fn.remainingReferences?.length || 0),
|
|
144
872
|
signatureMismatches: 0,
|
|
145
873
|
account: summarizeAccount(fn.impact?.account),
|
|
146
|
-
}
|
|
874
|
+
};
|
|
875
|
+
if (fn._kind === 'added') {
|
|
876
|
+
// A new declaration under a new name, in a type that is new
|
|
877
|
+
// too, cannot be reached by anything that existed.
|
|
878
|
+
const outcome = assessAddition(index, fn, definitionOf(index, fn), addedLinesByFile, fn.impact?.account, judge, true);
|
|
879
|
+
item.verdict = outcome.verdict;
|
|
880
|
+
item.reasons = outcome.reasons;
|
|
881
|
+
if (outcome.outside) item.sites = outcome.outside;
|
|
882
|
+
} else if (fn._kind === 'deleted') {
|
|
883
|
+
// Kinds the dead-code rule does not audit (fields, constants,
|
|
884
|
+
// aliases) are never cleared: what remains is the name's
|
|
885
|
+
// other occurrences, each a candidate.
|
|
886
|
+
const definite = byJudge(judge, definiteDependentsOf(index, {
|
|
887
|
+
name: fn.name, file: fn.filePath, relativePath: filePath, className: fn.className,
|
|
888
|
+
}), goneName(fn.name));
|
|
889
|
+
item.verdict = definite.checked.length > 0 ? 'blocked' : 'review';
|
|
890
|
+
item.reasons = definite.checked.length > 0 ? ['still-referenced']
|
|
891
|
+
: definite.unchecked.length > 0 ? ['site-not-checked']
|
|
892
|
+
: [(fn.remainingReferences || []).length > 0 ? 'name-still-used' : 'declaration-removed'];
|
|
893
|
+
if (definite.checked.length + definite.unchecked.length > 0) {
|
|
894
|
+
item.sites = [...definite.checked, ...definite.unchecked];
|
|
895
|
+
}
|
|
896
|
+
} else {
|
|
897
|
+
item.verdict = 'review';
|
|
898
|
+
item.reasons = [valueDeclaration ? 'value-change' : 'declaration-change'];
|
|
899
|
+
}
|
|
900
|
+
items.push(item);
|
|
147
901
|
continue;
|
|
148
902
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
(
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
903
|
+
|
|
904
|
+
if (fn._kind === 'modified') {
|
|
905
|
+
const change = fn.change || { kind: 'unknown', reasons: ['contract-unreadable'] };
|
|
906
|
+
const callers = Array.isArray(fn.callers) ? fn.callers : [];
|
|
907
|
+
const unverifiedCallers = Array.isArray(fn.unverifiedCallers) ? fn.unverifiedCallers : [];
|
|
908
|
+
const item = {
|
|
909
|
+
name: fn.name,
|
|
910
|
+
file: filePath,
|
|
911
|
+
line: fn.startLine || fn.line,
|
|
912
|
+
kind: 'modified',
|
|
913
|
+
change: change.kind,
|
|
914
|
+
callerCount: callers.length,
|
|
915
|
+
unverifiedCallerCount: unverifiedCallers.length,
|
|
916
|
+
signatureMismatches: 0,
|
|
917
|
+
account: summarizeAccount(fn.account || null),
|
|
918
|
+
};
|
|
919
|
+
if (change.kind === 'inert') {
|
|
920
|
+
// The contract every caller relies on is untouched.
|
|
921
|
+
item.verdict = 'ready';
|
|
922
|
+
item.reasons = [];
|
|
923
|
+
items.push(item);
|
|
924
|
+
continue;
|
|
925
|
+
}
|
|
926
|
+
// The base revision decides which call sites the changed
|
|
927
|
+
// declaration bound before (fix #394). An exception inside verify
|
|
928
|
+
// is an internal error, never a silently skipped check.
|
|
929
|
+
const verifyResult = index.verify(fn.name, {
|
|
930
|
+
file: filePath, line: fn.startLine, base: options.staged ? 'HEAD' : (options.base || 'HEAD'),
|
|
931
|
+
});
|
|
932
|
+
// Note: verify() returns `mismatches` as a COUNT and `mismatchDetails` as the array.
|
|
933
|
+
const mismatches = verifyResult && Array.isArray(verifyResult.mismatchDetails)
|
|
934
|
+
? verifyResult.mismatchDetails
|
|
935
|
+
: [];
|
|
936
|
+
// A call site the checker was told to skip is listed and does
|
|
937
|
+
// not block (a deliberately wrong call under `@ts-expect-error`
|
|
938
|
+
// is still one after the signature moves).
|
|
939
|
+
// The checker reports too few arguments at the call and one
|
|
940
|
+
// too many at that argument.
|
|
941
|
+
const most = mismatch => {
|
|
942
|
+
const counts = String(mismatch.expected === undefined ? '' : mismatch.expected).match(/\d+/g);
|
|
943
|
+
return counts ? Number(counts[counts.length - 1]) : undefined;
|
|
944
|
+
};
|
|
945
|
+
const marked = mismatches.map(mismatch =>
|
|
946
|
+
(judge.suppressed(mismatch, {
|
|
947
|
+
kind: 'arity', name: fn.name, most: most(mismatch), span: mismatch.span,
|
|
948
|
+
problem: mismatch.problem, indirect: mismatch.indirect,
|
|
949
|
+
}) ? { ...mismatch, unchecked: true } : mismatch));
|
|
950
|
+
item.signatureMismatches = marked.filter(mismatch => !mismatch.unchecked).length;
|
|
951
|
+
if (marked.length > 0) item.mismatches = marked;
|
|
952
|
+
if (item.signatureMismatches > 0) {
|
|
953
|
+
item.verdict = 'blocked';
|
|
954
|
+
item.reasons = ['signature-mismatch', ...change.reasons];
|
|
955
|
+
} else if (marked.length > 0) {
|
|
956
|
+
item.verdict = 'review';
|
|
957
|
+
item.reasons = ['site-not-checked', ...change.reasons];
|
|
958
|
+
} else {
|
|
959
|
+
// A changed contract matters only to what depends on it.
|
|
960
|
+
// What a method assigns to a member is the CLASS's
|
|
961
|
+
// contract: its dependents are the member's users, whoever
|
|
962
|
+
// calls the method.
|
|
963
|
+
const definition = definitionOf(index, fn);
|
|
964
|
+
let dependents;
|
|
965
|
+
try { dependents = definition ? assessDeletion(index, definition) : null; } catch (_) { dependents = null; }
|
|
966
|
+
if (dependents && dependents.verdict === 'no-static-dependents' &&
|
|
967
|
+
!change.reasons.includes('inferred-member-type')) {
|
|
968
|
+
item.verdict = 'ready';
|
|
969
|
+
item.reasons = [];
|
|
970
|
+
item.noDependents = true;
|
|
971
|
+
} else {
|
|
972
|
+
item.verdict = 'review';
|
|
973
|
+
item.reasons = [...change.reasons];
|
|
974
|
+
if (dependents && dependents.verdict === 'review') item.shield = dependents.reason;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
item.reviewItems = reviewItemsOf(unverifiedCallers);
|
|
978
|
+
items.push(item);
|
|
979
|
+
continue;
|
|
181
980
|
}
|
|
182
981
|
|
|
982
|
+
// Added callable.
|
|
983
|
+
const definition = definitionOf(index, fn);
|
|
984
|
+
const found = callersOf(index, fn.name, definition);
|
|
985
|
+
const outcome = assessAddition(index, fn, definition, addedLinesByFile, found.account, judge);
|
|
183
986
|
const item = {
|
|
184
987
|
name: fn.name,
|
|
185
988
|
file: filePath,
|
|
186
989
|
line: fn.startLine || fn.line,
|
|
187
|
-
kind:
|
|
188
|
-
callerCount:
|
|
189
|
-
unverifiedCallerCount:
|
|
190
|
-
signatureMismatches:
|
|
191
|
-
account: summarizeAccount(account),
|
|
192
|
-
|
|
990
|
+
kind: 'added',
|
|
991
|
+
callerCount: found.confirmed.length,
|
|
992
|
+
unverifiedCallerCount: found.unverified.length,
|
|
993
|
+
signatureMismatches: 0,
|
|
994
|
+
account: summarizeAccount(found.account),
|
|
995
|
+
verdict: outcome.verdict,
|
|
996
|
+
reasons: outcome.reasons,
|
|
997
|
+
...(outcome.outside && { sites: outcome.outside }),
|
|
193
998
|
};
|
|
194
|
-
|
|
195
999
|
// Orphan = newly added with zero caller CANDIDATES IN EITHER TIER and
|
|
196
|
-
// not
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
1000
|
+
// not an entry point. Zero confirmed + N unverified is NOT orphan
|
|
1001
|
+
// (the #223 reverseTrace entry-point soundness rule). Information
|
|
1002
|
+
// about the addition, never a reason to hold the change.
|
|
1003
|
+
if (found.confirmed.length === 0 && found.unverified.length === 0 && definition) {
|
|
1004
|
+
const language = index.files.get(definition.file)?.language;
|
|
1005
|
+
let adapter;
|
|
1006
|
+
try { adapter = language ? require('../languages').getLanguageAdapter(language) : null; } catch (_) { adapter = null; }
|
|
1007
|
+
let entryKind;
|
|
1008
|
+
try { entryKind = adapter?.getEntryPointKind ? adapter.getEntryPointKind(definition) : null; } catch (_) { entryKind = null; }
|
|
1009
|
+
let isEntry = !!entryKind;
|
|
1010
|
+
try { isEntry = isEntry || !!adapter?.isEntryPoint?.(definition); } catch (_) { /* keep */ }
|
|
204
1011
|
item.orphan = !isEntry;
|
|
205
1012
|
}
|
|
206
|
-
|
|
207
1013
|
items.push(item);
|
|
208
1014
|
}
|
|
209
1015
|
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
// called is a likely break, so they count as unverified callers here.
|
|
1016
|
+
// Deleted callables: the base revision's declaration, assessed against
|
|
1017
|
+
// what the snapshot still holds.
|
|
213
1018
|
for (const d of deleted) {
|
|
1019
|
+
const outcome = assessRemoval(index, d, judge);
|
|
214
1020
|
items.push({
|
|
215
1021
|
name: d.name || '(unnamed)',
|
|
216
1022
|
file: d.relativePath || d.file || '',
|
|
@@ -226,82 +1032,227 @@ function check(index, options = {}) {
|
|
|
226
1032
|
semanticComplete: false,
|
|
227
1033
|
safeToDelete: false,
|
|
228
1034
|
},
|
|
1035
|
+
verdict: outcome.verdict,
|
|
1036
|
+
reasons: outcome.reasons,
|
|
1037
|
+
...(outcome.basis && { basis: outcome.basis }),
|
|
1038
|
+
...(outcome.sites && { sites: outcome.sites }),
|
|
229
1039
|
});
|
|
230
1040
|
}
|
|
231
1041
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
1042
|
+
items.push(...moduleItems);
|
|
1043
|
+
|
|
1044
|
+
// Affected tests: every changed symbol, by its own declaration (a name
|
|
1045
|
+
// alone would pick the tests of every namesake). One row per file; the
|
|
1046
|
+
// tests are counted once however many symbols reach them.
|
|
1047
|
+
// One operation for the whole selection: every symbol's question reads
|
|
1048
|
+
// the same test files, and what one read parsed the next reuses.
|
|
1049
|
+
index._beginOp();
|
|
1050
|
+
let testFiles;
|
|
1051
|
+
let supportFiles;
|
|
1052
|
+
let testCount;
|
|
1053
|
+
let testsOpen = false;
|
|
1054
|
+
let testsConditional;
|
|
1055
|
+
let testCommands;
|
|
1056
|
+
try {
|
|
1057
|
+
const model = createTestModel(index);
|
|
1058
|
+
const linesByFile = new Map(); // relative test path -> { row, lines }
|
|
1059
|
+
const callerCache = new Map();
|
|
1060
|
+
for (const fn of changed) {
|
|
1061
|
+
let t = null;
|
|
1062
|
+
try {
|
|
1063
|
+
t = index.affectedTests(fn.name, {
|
|
1064
|
+
depth: 2,
|
|
1065
|
+
file: fn.relativePath || fn.file || undefined,
|
|
1066
|
+
line: fn.startLine || undefined,
|
|
1067
|
+
className: fn.className || undefined,
|
|
1068
|
+
_callerCache: callerCache,
|
|
1069
|
+
_testModel: model,
|
|
1070
|
+
});
|
|
1071
|
+
} catch (e) {
|
|
1072
|
+
t = null;
|
|
1073
|
+
}
|
|
1074
|
+
for (const tf of (t && t.testFiles) || []) {
|
|
1075
|
+
let held = linesByFile.get(tf.file);
|
|
1076
|
+
if (!held) {
|
|
1077
|
+
held = { row: tf, lines: new Set(), linked: new Set() };
|
|
1078
|
+
linesByFile.set(tf.file, held);
|
|
1079
|
+
}
|
|
1080
|
+
for (const name of tf.linkedFunctions || []) held.linked.add(name);
|
|
1081
|
+
for (const match of tf.matches || []) {
|
|
1082
|
+
if (match.matchType === 'call' || match.matchType === 'test-case') held.lines.add(match.line);
|
|
243
1083
|
}
|
|
244
1084
|
}
|
|
245
1085
|
}
|
|
1086
|
+
const absoluteTest = new Map();
|
|
1087
|
+
for (const [file, entry] of index.files) {
|
|
1088
|
+
if (linesByFile.has(entry.relativePath)) absoluteTest.set(entry.relativePath, file);
|
|
1089
|
+
}
|
|
1090
|
+
// The lines each file's links stand on; a test a class inherits is
|
|
1091
|
+
// carried to the file of the class that runs it, which may be a file
|
|
1092
|
+
// no link stands in.
|
|
1093
|
+
const linesOf = new Map();
|
|
1094
|
+
for (const [rel, held] of linesByFile) {
|
|
1095
|
+
if (absoluteTest.has(rel)) linesOf.set(absoluteTest.get(rel), [...held.lines]);
|
|
1096
|
+
}
|
|
1097
|
+
testFiles = [];
|
|
1098
|
+
supportFiles = [];
|
|
1099
|
+
const countedTests = new Set();
|
|
1100
|
+
const conditionalTests = new Set();
|
|
1101
|
+
const runnable = [];
|
|
1102
|
+
for (const [absFile, linked] of linkFiles(model, index, linesOf)) {
|
|
1103
|
+
const rel = index.files.get(absFile).relativePath;
|
|
1104
|
+
const held = linesByFile.get(rel);
|
|
1105
|
+
const names = new Set(held ? held.linked : []);
|
|
1106
|
+
for (const from of linked.from || []) {
|
|
1107
|
+
for (const name of (linesByFile.get(from) || { linked: [] }).linked) names.add(name);
|
|
1108
|
+
}
|
|
1109
|
+
const row = {
|
|
1110
|
+
file: rel,
|
|
1111
|
+
role: linked.role,
|
|
1112
|
+
...(linked.runner && { runner: linked.runner }),
|
|
1113
|
+
linkedFunctions: [...names].sort(),
|
|
1114
|
+
testCount: linked.tests,
|
|
1115
|
+
...(linked.moreTests && { moreTests: true }),
|
|
1116
|
+
...(linked.conditionalTests && { conditionalTests: linked.conditionalTests }),
|
|
1117
|
+
...(linked.run && { run: linked.run }),
|
|
1118
|
+
...(linked.runWithheld && { runWithheld: linked.runWithheld }),
|
|
1119
|
+
};
|
|
1120
|
+
if (linked.role === 'support') {
|
|
1121
|
+
supportFiles.push(row);
|
|
1122
|
+
continue;
|
|
1123
|
+
}
|
|
1124
|
+
testFiles.push(row);
|
|
1125
|
+
runnable.push(absFile);
|
|
1126
|
+
for (const key of linked.testKeys) countedTests.add(key);
|
|
1127
|
+
for (const key of linked.conditionalKeys) conditionalTests.add(key);
|
|
1128
|
+
if (linked.moreTests) testsOpen = true;
|
|
1129
|
+
}
|
|
1130
|
+
testFiles.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
|
|
1131
|
+
supportFiles.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
|
|
1132
|
+
testCount = countedTests.size;
|
|
1133
|
+
testsConditional = conditionalTests.size;
|
|
1134
|
+
testCommands = commandsFor(model, runnable.sort());
|
|
1135
|
+
} finally { index._endOp(); }
|
|
246
1136
|
|
|
247
|
-
// Action items
|
|
1137
|
+
// Action items: one per reason an item is not ready, then what else the
|
|
1138
|
+
// evidence about a non-ready item says.
|
|
248
1139
|
const actions = [];
|
|
1140
|
+
const reasonText = code => CHANGE_TEXT[code] || HAZARD_TEXT[code] || REASON_TEXT[code] || null;
|
|
249
1141
|
for (const it of items) {
|
|
250
|
-
if (it.
|
|
1142
|
+
if (it.kind !== 'module') continue;
|
|
1143
|
+
for (const code of it.reasons) {
|
|
251
1144
|
actions.push({
|
|
252
|
-
severity: 'warn',
|
|
253
|
-
kind: '
|
|
254
|
-
message: `${it.
|
|
255
|
-
|
|
256
|
-
} else if (it.requiresToolchainValidation) {
|
|
257
|
-
actions.push({
|
|
258
|
-
severity: it.kind === 'added' ? 'warn' : 'error',
|
|
259
|
-
kind: 'declaration_change',
|
|
260
|
-
message: `${it.name}: ${it.kind} ${it.symbolType}; shape/inheritance compatibility is not checked by arity analysis — review dependency sites and run the compiler/type checker and tests`,
|
|
1145
|
+
severity: it.verdict === 'blocked' ? 'error' : 'warn',
|
|
1146
|
+
kind: 'module_change',
|
|
1147
|
+
message: `${it.file}: ${MODULE_TEXT[code] || code}` +
|
|
1148
|
+
(it.verdict === 'blocked' ? ` (${it.sites.length} site(s))` : ''),
|
|
261
1149
|
});
|
|
262
1150
|
}
|
|
263
|
-
|
|
1151
|
+
}
|
|
1152
|
+
for (const it of items) {
|
|
1153
|
+
if (it.kind === 'module') continue;
|
|
1154
|
+
if (it.verdict === 'ready') {
|
|
1155
|
+
if (it.orphan) {
|
|
1156
|
+
actions.push({
|
|
1157
|
+
severity: 'info',
|
|
1158
|
+
kind: 'orphan_new',
|
|
1159
|
+
message: `${it.name} is new and nothing calls it yet; it is not a detected entry point`,
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
continue;
|
|
1163
|
+
}
|
|
1164
|
+
if (it.signatureMismatches > 0) {
|
|
264
1165
|
actions.push({
|
|
265
1166
|
severity: 'error',
|
|
266
|
-
kind: '
|
|
267
|
-
message: `${it.name}:
|
|
1167
|
+
kind: 'signature_drift',
|
|
1168
|
+
message: `${it.name}: ${it.signatureMismatches} call site(s) no longer fit the signature`,
|
|
268
1169
|
});
|
|
269
1170
|
}
|
|
270
|
-
if (it.
|
|
1171
|
+
if (it.kind === 'deleted' && it.verdict === 'blocked') {
|
|
271
1172
|
actions.push({
|
|
272
|
-
severity: '
|
|
273
|
-
kind: '
|
|
274
|
-
message: `${it.name}:
|
|
1173
|
+
severity: 'error',
|
|
1174
|
+
kind: 'deleted_still_referenced',
|
|
1175
|
+
message: `${it.name}: deleted, and ${it.sites.length} site(s) still import or call it`,
|
|
275
1176
|
});
|
|
1177
|
+
} else if (it.kind === 'deleted') {
|
|
1178
|
+
const why = it.reasons[0] === 'name-still-used'
|
|
1179
|
+
? 'the name is still used; check whether those uses meant this declaration'
|
|
1180
|
+
: it.reasons[0] === 'declaration-removed'
|
|
1181
|
+
? 'a declaration of this kind is not cleared by the engine; run the compiler/type checker and tests'
|
|
1182
|
+
: (it.basis || reasonText(it.reasons[0]) || it.reasons[0]);
|
|
1183
|
+
actions.push({ severity: 'warn', kind: 'deleted_review', message: `${it.name}: deleted; ${why}` });
|
|
276
1184
|
}
|
|
277
|
-
if (it.
|
|
278
|
-
actions.push({
|
|
1185
|
+
if (it.requiresToolchainValidation && it.kind === 'modified') {
|
|
1186
|
+
actions.push(it.valueDeclaration ? {
|
|
279
1187
|
severity: 'warn',
|
|
280
|
-
kind: '
|
|
281
|
-
message: `${it.name}: ${it.
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
if (it.account && it.account.requiresUsageReview) {
|
|
285
|
-
actions.push({
|
|
1188
|
+
kind: 'value_change',
|
|
1189
|
+
message: `${it.name}: modified ${it.symbolType}; review its ${it.dependencyCount + it.callerCount} value access site(s) and run the compiler/type checker and tests`,
|
|
1190
|
+
} : {
|
|
286
1191
|
severity: 'warn',
|
|
287
|
-
kind: '
|
|
288
|
-
message: `${it.name}:
|
|
1192
|
+
kind: 'declaration_change',
|
|
1193
|
+
message: `${it.name}: modified ${it.symbolType}; shape/inheritance compatibility is not checked by arity analysis — review dependency sites and run the compiler/type checker and tests`,
|
|
289
1194
|
});
|
|
290
1195
|
}
|
|
291
|
-
if (it.
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
1196
|
+
if (it.kind === 'modified' && !it.requiresToolchainValidation) {
|
|
1197
|
+
const moved = it.reasons.filter(code => CHANGE_TEXT[code]).map(code => CHANGE_TEXT[code]);
|
|
1198
|
+
if (moved.length > 0) {
|
|
1199
|
+
actions.push({
|
|
1200
|
+
severity: 'warn',
|
|
1201
|
+
kind: 'contract_change',
|
|
1202
|
+
message: `${it.name}: ${moved.join('; ')}; review its ${it.callerCount} caller(s)` +
|
|
1203
|
+
(it.shield ? ` (${reasonText(it.shield) || it.shield})` : ''),
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
297
1206
|
}
|
|
298
|
-
if (it.
|
|
1207
|
+
if (it.kind === 'added' && it.verdict === 'blocked') {
|
|
299
1208
|
actions.push({
|
|
300
|
-
severity: '
|
|
301
|
-
kind: '
|
|
302
|
-
message: `${it.name}
|
|
1209
|
+
severity: 'error',
|
|
1210
|
+
kind: 'addition_breaks',
|
|
1211
|
+
message: `${it.name}: ${(it.sites || []).length} object literal(s) declared as its type do not have it`,
|
|
303
1212
|
});
|
|
304
1213
|
}
|
|
1214
|
+
if (it.kind === 'added') {
|
|
1215
|
+
for (const code of it.reasons) {
|
|
1216
|
+
if (code === 'literal-lacks-new-member') continue;
|
|
1217
|
+
actions.push({
|
|
1218
|
+
severity: 'warn',
|
|
1219
|
+
kind: 'addition_hazard',
|
|
1220
|
+
message: `${it.name}: ${reasonText(code) || code}`,
|
|
1221
|
+
});
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
// Evidence about callers matters where the callers do.
|
|
1225
|
+
if (it.kind !== 'deleted') {
|
|
1226
|
+
if (!it.account || !it.account.textComplete) {
|
|
1227
|
+
actions.push({
|
|
1228
|
+
severity: 'warn',
|
|
1229
|
+
kind: 'incomplete_account',
|
|
1230
|
+
message: `${it.name}: caller evidence is not text-complete` +
|
|
1231
|
+
`${incompleteReason(it.account)}; inspect warnings and usages manually`,
|
|
1232
|
+
});
|
|
1233
|
+
}
|
|
1234
|
+
if (it.unverifiedCallerCount > 0) {
|
|
1235
|
+
actions.push({
|
|
1236
|
+
severity: 'warn',
|
|
1237
|
+
kind: 'unverified_callers',
|
|
1238
|
+
message: `${it.name}: review ${it.unverifiedCallerCount} unverified caller candidate(s)`,
|
|
1239
|
+
});
|
|
1240
|
+
}
|
|
1241
|
+
if (it.account && it.account.filtered > 0) {
|
|
1242
|
+
actions.push({
|
|
1243
|
+
severity: 'warn',
|
|
1244
|
+
kind: 'filtered_evidence',
|
|
1245
|
+
message: `${it.name}: ${it.account.filtered} caller edge(s) were hidden by filters`,
|
|
1246
|
+
});
|
|
1247
|
+
}
|
|
1248
|
+
if (it.account && it.account.requiresUsageReview) {
|
|
1249
|
+
actions.push({
|
|
1250
|
+
severity: 'warn',
|
|
1251
|
+
kind: 'non_call_usages',
|
|
1252
|
+
message: `${it.name}: non-call references require a usages review`,
|
|
1253
|
+
});
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
305
1256
|
}
|
|
306
1257
|
if (limit && allChanged.length > limit) {
|
|
307
1258
|
actions.push({
|
|
@@ -310,26 +1261,40 @@ function check(index, options = {}) {
|
|
|
310
1261
|
message: `${allChanged.length - limit} changed symbol(s) were not checked; rerun without --limit`,
|
|
311
1262
|
});
|
|
312
1263
|
}
|
|
313
|
-
if (
|
|
314
|
-
|
|
1264
|
+
if (testCommands.length > 0) {
|
|
1265
|
+
// The runner's own commands, where a manifest names the runner.
|
|
1266
|
+
const shown = testCommands.slice(0, 3);
|
|
315
1267
|
actions.push({
|
|
316
1268
|
severity: 'info',
|
|
317
1269
|
kind: 'tests_to_run',
|
|
318
|
-
message: `Run tests: ${
|
|
1270
|
+
message: `Run tests: ${shown.join(' ; ')}` +
|
|
1271
|
+
(testCommands.length > shown.length ? ` (+${testCommands.length - shown.length} more command(s))` : ''),
|
|
1272
|
+
commands: testCommands,
|
|
319
1273
|
});
|
|
1274
|
+
} else {
|
|
1275
|
+
// No runner is proven: the files are named, without a command.
|
|
1276
|
+
// A file whose own answer says running it would not run its tests
|
|
1277
|
+
// (declared inside functions, switched off, left out of the
|
|
1278
|
+
// build) is not named.
|
|
1279
|
+
const named = testFiles.filter(row => !FILE_WOULD_NOT_RUN.has(row.runWithheld));
|
|
1280
|
+
if (named.length > 0) {
|
|
1281
|
+
actions.push({
|
|
1282
|
+
severity: 'info',
|
|
1283
|
+
kind: 'tests_to_run',
|
|
1284
|
+
message: `Run tests: ${named.slice(0, 5).map(t => t.file).join(' ')}` +
|
|
1285
|
+
(named.length > 5 ? ` (+${named.length - 5} more)` : ''),
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
320
1288
|
}
|
|
321
1289
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
const
|
|
325
|
-
const
|
|
326
|
-
const
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
const
|
|
330
|
-
signatureMismatches > 0 || filteredEdges > 0 || usageReviewSymbols > 0 ||
|
|
331
|
-
actions.some(a => a.kind === 'orphan_new' || a.kind === 'declaration_change' || a.kind === 'value_change') ||
|
|
332
|
-
!!(limit && allChanged.length > limit);
|
|
1290
|
+
// Counts describe the items whose callers matter: an inert change has
|
|
1291
|
+
// callers, and none of them is something to look at.
|
|
1292
|
+
const open = items.filter(it => it.verdict !== 'ready');
|
|
1293
|
+
const blocked = items.filter(it => it.verdict === 'blocked').length;
|
|
1294
|
+
const review = items.filter(it => it.verdict === 'review').length;
|
|
1295
|
+
const truncated = !!(limit && allChanged.length > limit);
|
|
1296
|
+
const worst = items.reduce((rank, it) => Math.max(rank, VERDICT_RANK[it.verdict] || 0), truncated ? 1 : 0);
|
|
1297
|
+
const callerItems = open.filter(it => it.kind !== 'deleted' && it.kind !== 'module');
|
|
333
1298
|
|
|
334
1299
|
return {
|
|
335
1300
|
base: options.base || 'HEAD',
|
|
@@ -338,23 +1303,36 @@ function check(index, options = {}) {
|
|
|
338
1303
|
status: 'checked',
|
|
339
1304
|
...pathCounts,
|
|
340
1305
|
changed: items,
|
|
341
|
-
totalChanged: allChanged.length + deleted.length,
|
|
342
|
-
truncated
|
|
1306
|
+
totalChanged: allChanged.length + deleted.length + moduleItems.length,
|
|
1307
|
+
truncated,
|
|
343
1308
|
testFiles,
|
|
1309
|
+
...(supportFiles.length > 0 && { supportFiles }),
|
|
344
1310
|
totalTestFiles: testFiles.length,
|
|
345
1311
|
totalTests: testCount,
|
|
1312
|
+
// A file among them holds tests that are not listed: the total
|
|
1313
|
+
// is a lower bound.
|
|
1314
|
+
...(testsOpen && { moreTests: true }),
|
|
1315
|
+
...(testsConditional > 0 && { conditionalTests: testsConditional }),
|
|
346
1316
|
actions,
|
|
347
1317
|
trust: {
|
|
348
|
-
status:
|
|
349
|
-
|
|
350
|
-
|
|
1318
|
+
status: worst === 2 ? 'BLOCKED' : worst === 1 ? 'REVIEW_REQUIRED' : 'READY_FOR_TOOLCHAIN',
|
|
1319
|
+
// The one boolean a gate needs: a break never passes; something
|
|
1320
|
+
// to look at passes unless the caller asked for strict.
|
|
1321
|
+
passes: worst === 0 || (worst === 1 && !options.strict),
|
|
1322
|
+
...(options.strict && { strict: true }),
|
|
1323
|
+
blocked,
|
|
1324
|
+
review,
|
|
1325
|
+
ready: items.length - blocked - review,
|
|
351
1326
|
accountsChecked: items.filter(it => it.account && it.account.available).length,
|
|
352
|
-
incompleteAccounts,
|
|
353
|
-
unverifiedCallSites,
|
|
354
|
-
signatureMismatches,
|
|
355
|
-
filteredEdges,
|
|
356
|
-
usageReviewSymbols,
|
|
357
|
-
unvalidatedDeclarations,
|
|
1327
|
+
incompleteAccounts: callerItems.filter(it => !it.account || !it.account.textComplete).length,
|
|
1328
|
+
unverifiedCallSites: callerItems.reduce((sum, it) => sum + (it.unverifiedCallerCount || 0), 0),
|
|
1329
|
+
signatureMismatches: items.reduce((sum, it) => sum + (it.signatureMismatches || 0), 0),
|
|
1330
|
+
filteredEdges: callerItems.reduce((sum, it) => sum + (it.account ? it.account.filtered || 0 : 0), 0),
|
|
1331
|
+
usageReviewSymbols: callerItems.filter(it => it.account && it.account.requiresUsageReview).length,
|
|
1332
|
+
unvalidatedDeclarations: open.filter(it => it.requiresToolchainValidation).length,
|
|
1333
|
+
// What there is to look at among unverified callers: one per
|
|
1334
|
+
// site a reader can settle, one per dispatch family.
|
|
1335
|
+
reviewItems: callerItems.reduce((sum, it) => sum + (it.reviewItems ? it.reviewItems.items : 0), 0),
|
|
358
1336
|
semanticComplete: false,
|
|
359
1337
|
safeToDelete: false,
|
|
360
1338
|
requiresCompilerAndTests: true,
|