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