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.
Files changed (71) hide show
  1. package/.claude/skills/ucn/SKILL.md +35 -12
  2. package/.claude/skills/ucn/references/commands.md +18 -14
  3. package/.claude/skills/ucn/references/resolution-rules.md +4 -0
  4. package/.claude/skills/ucn/references/trust-contract.md +4 -4
  5. package/CHANGELOG.md +474 -0
  6. package/README.md +69 -29
  7. package/cli/flags.js +184 -0
  8. package/cli/index.js +137 -324
  9. package/core/account.js +44 -87
  10. package/core/analysis.js +273 -107
  11. package/core/ast-analysis.js +2 -0
  12. package/core/bridge.js +14 -0
  13. package/core/cache.js +101 -16
  14. package/core/callers.js +352 -70
  15. package/core/capture.js +331 -0
  16. package/core/change-kinds.js +888 -0
  17. package/core/check.js +1117 -139
  18. package/core/command-contracts.js +7 -6
  19. package/core/constructed-class.js +1195 -0
  20. package/core/contract-membership.js +142 -0
  21. package/core/deadcode.js +362 -84
  22. package/core/deletion.js +401 -0
  23. package/core/discovery.js +104 -9
  24. package/core/entrypoints.js +12 -6
  25. package/core/evidence-bands.js +68 -0
  26. package/core/evidence-bands.json +44 -0
  27. package/core/execute.js +172 -161
  28. package/core/hot-summary.js +241 -0
  29. package/core/index-ir.js +3 -1
  30. package/core/ir.js +5 -1
  31. package/core/member-signatures.js +789 -0
  32. package/core/output/analysis-ext.js +20 -10
  33. package/core/output/analysis.js +98 -60
  34. package/core/output/check.js +98 -37
  35. package/core/output/doctor.js +34 -1
  36. package/core/output/find.js +2 -1
  37. package/core/output/lines.js +63 -3
  38. package/core/output/public.js +29 -11
  39. package/core/output/refactoring.js +32 -0
  40. package/core/output/reporting.js +15 -2
  41. package/core/output/search.js +11 -2
  42. package/core/output/tracing.js +35 -7
  43. package/core/output-budget.js +124 -57
  44. package/core/parallel-build.js +3 -3
  45. package/core/project.js +106 -23
  46. package/core/provenance-type.js +12 -5
  47. package/core/public-command.js +35 -9
  48. package/core/python-modules.js +6 -0
  49. package/core/reflection.js +151 -23
  50. package/core/registry.js +623 -72
  51. package/core/reporting.js +69 -34
  52. package/core/review-items.js +75 -0
  53. package/core/search.js +89 -128
  54. package/core/shared.js +0 -45
  55. package/core/snapshot.js +350 -0
  56. package/core/source-universe.js +707 -0
  57. package/core/test-units.js +3624 -0
  58. package/core/tracing.js +150 -96
  59. package/core/verify.js +27 -11
  60. package/languages/adapter.js +8 -3
  61. package/languages/c-family.js +12 -0
  62. package/languages/csharp.js +634 -0
  63. package/languages/go.js +120 -0
  64. package/languages/index.js +422 -4
  65. package/languages/java.js +712 -0
  66. package/languages/javascript.js +1460 -2
  67. package/languages/lexical-scope.js +68 -0
  68. package/languages/python.js +1672 -1
  69. package/languages/rust.js +91 -0
  70. package/mcp/server.js +44 -110
  71. package/package.json +10 -5
package/core/check.js CHANGED
@@ -1,18 +1,82 @@
1
1
  /**
2
- * core/check.js — Pre-commit summary command.
2
+ * core/check.js - Pre-commit check: what can this change break?
3
3
  *
4
- * Composes diff-impact + verify + affected-tests into a single output.
5
- * Tells the caller, in one shot:
6
- * - which functions changed
7
- * - which call sites might break (signature drift)
8
- * - which tests are likely affected
9
- * - which new functions look orphaned
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 { isValueDefinition } = require('./shared');
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(index, options = {}) {
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
- if (!dr || (allChanged.length === 0 && deleted.length === 0)) {
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
- items.push({
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
- // The base revision decides which call sites the changed
150
- // declaration bound before (fix #394). An exception inside verify is
151
- // an internal error, never a silently skipped check.
152
- const verifyResult = index.verify(fn.name, {
153
- file: filePath, line: fn.startLine, base: options.staged ? 'HEAD' : (options.base || 'HEAD'),
154
- });
155
- // Note: verify() returns `mismatches` as a COUNT and `mismatchDetails` as the array.
156
- const mismatches = verifyResult && Array.isArray(verifyResult.mismatchDetails)
157
- ? verifyResult.mismatchDetails
158
- : [];
159
-
160
- // For modified functions, the contracted diffImpact result carries both
161
- // bands already (confirmed `callers` + visible `unverifiedCallers`).
162
- let callers = Array.isArray(fn.callers) ? fn.callers : [];
163
- let unverifiedCallers = Array.isArray(fn.unverifiedCallers) ? fn.unverifiedCallers : [];
164
- let account = fn.account || null;
165
- // An exception here is an internal error, never a silent orphan claim
166
- // (fix #394).
167
- if (callers.length === 0 && fn._kind === 'added') {
168
- const definitions = (index.symbols.get(fn.name) || []).filter(d =>
169
- (d.relativePath === filePath || d.file === fn.filePath) &&
170
- (!fn.startLine || d.startLine === fn.startLine));
171
- const raw = index.findCallers(fn.name, {
172
- includeMethods: true,
173
- collectAccount: true,
174
- targetDefinitions: definitions.length > 0 ? definitions : undefined,
175
- }) || [];
176
- callers = raw.filter(c => c.tier !== 'unverified');
177
- unverifiedCallers = raw.filter(c => c.tier === 'unverified')
178
- .concat(raw.unverifiedEntries || []);
179
- account = composeAccount(index, fn.name, raw);
180
- unverifiedCallers = unverifiedCallers.concat(callNotResolvedEntries(index, account));
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: fn._kind,
188
- callerCount: callers.length,
189
- unverifiedCallerCount: unverifiedCallers.length,
190
- signatureMismatches: mismatches.length,
191
- account: summarizeAccount(account),
192
- ...(mismatches.length > 0 && { mismatches: mismatches.slice(0, 5) }),
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 detected as an entry point. Zero confirmed + N unverified is NOT
197
- // orphan (the #223 reverseTrace entry-point soundness rule — claiming
198
- // "nobody calls this" after routing candidates to the unverified tier
199
- // would be the exact silent-drop the contract forbids).
200
- if (item.kind === 'added' && callers.length === 0 && unverifiedCallers.length === 0) {
201
- // Check entry points: if the symbol is a known entry-point pattern, not orphan
202
- const eps = require('./entrypoints').detectEntrypoints(index) || [];
203
- const isEntry = eps.some(e => e.name === fn.name && (e.file === filePath || e.relativePath === filePath));
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
- // Surface deleted functions inline. remainingCallSites are name-level
211
- // matches still present in the tree — a deleted function that is still
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
- // Affected tests (top-level summary, capped)
233
- let testFiles = [];
234
- let testCount = 0;
235
- for (const fn of changed.slice(0, 10)) {
236
- const t = index.affectedTests(fn.name, { depth: 2 });
237
- if (t && t.testFiles) {
238
- for (const tf of t.testFiles) {
239
- if (!testFiles.find(x => x.file === tf.file)) {
240
- testFiles.push(tf);
241
- testCount += tf.testCount || 0;
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.requiresToolchainValidation && it.valueDeclaration && it.kind !== 'deleted') {
1142
+ if (it.kind !== 'module') continue;
1143
+ for (const code of it.reasons) {
251
1144
  actions.push({
252
- severity: 'warn',
253
- kind: 'value_change',
254
- message: `${it.name}: ${it.kind} ${it.symbolType}; review its ${it.dependencyCount + it.callerCount} value access site(s) and run the compiler/type checker and tests`,
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
- if (!it.account || !it.account.textComplete) {
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: 'incomplete_account',
267
- message: `${it.name}: caller evidence is not text-complete; inspect warnings and usages manually`,
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.unverifiedCallerCount > 0) {
1171
+ if (it.kind === 'deleted' && it.verdict === 'blocked') {
271
1172
  actions.push({
272
- severity: 'warn',
273
- kind: 'unverified_callers',
274
- message: `${it.name}: review ${it.unverifiedCallerCount} unverified caller candidate(s)`,
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.account && it.account.filtered > 0) {
278
- actions.push({
1185
+ if (it.requiresToolchainValidation && it.kind === 'modified') {
1186
+ actions.push(it.valueDeclaration ? {
279
1187
  severity: 'warn',
280
- kind: 'filtered_evidence',
281
- message: `${it.name}: ${it.account.filtered} caller edge(s) were hidden by filters`,
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: 'non_call_usages',
288
- message: `${it.name}: non-call references require a usages review`,
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.signatureMismatches > 0) {
292
- actions.push({
293
- severity: 'warn',
294
- kind: 'signature_drift',
295
- message: `${it.name}: ${it.signatureMismatches} call site(s) need updating`,
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.orphan) {
1207
+ if (it.kind === 'added' && it.verdict === 'blocked') {
299
1208
  actions.push({
300
- severity: 'warn',
301
- kind: 'orphan_new',
302
- message: `${it.name} is new with no observed caller candidates and is not a detected entry point; review usages before treating it as orphaned`,
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 (testFiles.length > 0) {
314
- const filesList = testFiles.slice(0, 5).map(t => t.file).join(' ');
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: ${filesList}`,
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
- const incompleteAccounts = items.filter(it => !it.account || !it.account.textComplete).length;
323
- const unverifiedCallSites = items.reduce((sum, it) => sum + (it.unverifiedCallerCount || 0), 0);
324
- const signatureMismatches = items.reduce((sum, it) => sum + (it.signatureMismatches || 0), 0);
325
- const filteredEdges = items.reduce((sum, it) => sum + (it.account ? it.account.filtered || 0 : 0), 0);
326
- const usageReviewSymbols = items.filter(it => it.account && it.account.requiresUsageReview).length;
327
- const unvalidatedDeclarations = items.filter(it => it.requiresToolchainValidation && it.kind !== 'added' &&
328
- !(it.valueDeclaration && it.kind === 'modified')).length;
329
- const reviewRequired = incompleteAccounts > 0 || unverifiedCallSites > 0 ||
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: !!(limit && allChanged.length > limit),
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: incompleteAccounts > 0 || signatureMismatches > 0 || unvalidatedDeclarations > 0
349
- ? 'BLOCKED'
350
- : reviewRequired ? 'REVIEW_REQUIRED' : 'READY_FOR_TOOLCHAIN',
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,