ucn 5.6.0 → 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 +4 -2
- package/.claude/skills/ucn/references/commands.md +1 -1
- package/CHANGELOG.md +11 -0
- package/README.md +5 -8
- package/core/analysis.js +3 -3
- package/core/cache.js +3 -1
- package/core/call-fit.js +130 -0
- package/core/callers.js +1 -1
- package/core/capture.js +40 -8
- package/core/change-kinds.js +221 -2
- package/core/check.js +48 -15
- package/core/command-contracts.js +2 -2
- package/core/deadcode.js +94 -23
- package/core/deletion.js +29 -9
- package/core/entrypoint-toml.js +110 -0
- package/core/index-ir.js +2 -2
- package/core/ir.js +1 -1
- package/core/outgoing-check.js +116 -0
- package/core/output/check.js +5 -2
- package/core/output/reporting.js +4 -0
- package/core/output/search.js +3 -0
- package/core/output/tracing.js +1 -1
- package/core/output-budget.js +29 -11
- package/core/python-binding-identity.js +707 -0
- package/core/reflection.js +99 -10
- package/core/registry.js +2 -2
- package/core/runtime-entrypoints.js +159 -0
- package/core/search.js +61 -38
- package/core/test-units.js +116 -42
- package/core/tracing.js +193 -635
- package/core/verify.js +37 -12
- package/languages/csharp.js +4 -4
- package/languages/go.js +1 -1
- package/languages/index.js +45 -1
- package/languages/java.js +4 -4
- package/languages/javascript.js +40 -6
- package/languages/python.js +49 -15
- package/languages/rust.js +126 -112
- package/languages/static-js.js +88 -0
- package/mcp/server.js +1 -1
- package/package.json +4 -3
package/core/change-kinds.js
CHANGED
|
@@ -54,6 +54,9 @@ const CASE_NODES = new Set(['switch_case', 'switch_default']);
|
|
|
54
54
|
// after the block is reached is a property of the manager.
|
|
55
55
|
const MANAGER_NODES = new Set(['with_statement', 'with_clause']);
|
|
56
56
|
const CALL_NODES = new Set(['call_expression', 'call']);
|
|
57
|
+
// Invocation syntax across the parsers; member references are not calls.
|
|
58
|
+
const OUTGOING_CALL_NODES = new Set(['call_expression', 'call', 'method_invocation',
|
|
59
|
+
'invocation_expression', 'object_creation_expression', 'new_expression']);
|
|
57
60
|
const AWAIT_NODES = new Set(['await_expression', 'await']);
|
|
58
61
|
|
|
59
62
|
// Nodes that hold declarations rather than being one: a callable's
|
|
@@ -120,6 +123,29 @@ function tokensOf(node, skip, out = []) {
|
|
|
120
123
|
return out;
|
|
121
124
|
}
|
|
122
125
|
|
|
126
|
+
// A baseline invocation is reused only when the whole callable still has
|
|
127
|
+
// the same written context, apart from direct primitive call arguments.
|
|
128
|
+
// This is a preservation premise, not a proof that an arbitrary new call
|
|
129
|
+
// is available (access, generics, receiver modes and result use included).
|
|
130
|
+
const DIRECT_LITERAL_NODES = new Set(['number', 'integer', 'float', 'number_literal',
|
|
131
|
+
'integer_literal', 'int_literal', 'decimal_integer_literal', 'float_literal', 'real_literal',
|
|
132
|
+
'string', 'string_literal', 'true', 'false', 'boolean_literal']);
|
|
133
|
+
function invocationTokens(node, out = []) {
|
|
134
|
+
if (!node || COMMENT_NODES.has(node.type)) return out;
|
|
135
|
+
const parent = node.parent;
|
|
136
|
+
const list = parent?.type === 'argument' && parent.childCount === 1 ? parent.parent : parent;
|
|
137
|
+
if (DIRECT_LITERAL_NODES.has(node.type) && list &&
|
|
138
|
+
['arguments', 'argument_list'].includes(list.type)) {
|
|
139
|
+
const kind = ['true', 'false', 'boolean_literal'].includes(node.type) ? 'boolean'
|
|
140
|
+
: ['string', 'string_literal'].includes(node.type) && ['"', "'"].includes(node.text[0]) ? 'string'
|
|
141
|
+
: /^\d+$/.test(node.text) && Number(node.text) <= 127 ? 'smallInteger' : null;
|
|
142
|
+
if (kind) { out.push(['argument-literal', kind]); return out; }
|
|
143
|
+
}
|
|
144
|
+
if (node.childCount === 0) { out.push([node.type, node.text]); return out; }
|
|
145
|
+
for (const child of node.children) invocationTokens(child, out);
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
123
149
|
function identifiersOf(node, out = new Set()) {
|
|
124
150
|
if (!node) return out;
|
|
125
151
|
if (node.childCount === 0) {
|
|
@@ -659,7 +685,102 @@ function callableContract(root, language, symbol, context = null) {
|
|
|
659
685
|
}
|
|
660
686
|
memberBindings = JSON.stringify(parts.sort());
|
|
661
687
|
}
|
|
662
|
-
|
|
688
|
+
const outgoingCalls = [];
|
|
689
|
+
const contextStatements = [];
|
|
690
|
+
// Native comment queries preserve extra-node identity. The binding's
|
|
691
|
+
// ordinary children wrappers can expose a Python inline-suite comment
|
|
692
|
+
// as a zero-child block, so type-testing that walk loses directives.
|
|
693
|
+
const bodyComments = body ? body.descendantsOfType([...COMMENT_NODES]) : [];
|
|
694
|
+
const commentSpans = new Set(bodyComments.map(comment => `${comment.startIndex}:${comment.endIndex}`));
|
|
695
|
+
const comments = bodyComments.filter(comment => traits.checkDirectiveComment?.(comment.text));
|
|
696
|
+
const visit = candidate => {
|
|
697
|
+
if (OUTGOING_CALL_NODES.has(candidate.type)) outgoingCalls.push({ node: candidate,
|
|
698
|
+
start: candidate.startIndex, end: candidate.endIndex });
|
|
699
|
+
if (!sameNode(candidate, body) && (bindingTargets(candidate).length || STATEMENT_SHAPE.test(candidate.type))) {
|
|
700
|
+
contextStatements.push(candidate);
|
|
701
|
+
}
|
|
702
|
+
return true;
|
|
703
|
+
};
|
|
704
|
+
if (body) { visit(body); walkOwn(body, visit); }
|
|
705
|
+
const parameters = node.childForFieldName('parameters') || node.childForFieldName('parameter');
|
|
706
|
+
// A discard occupies syntax but introduces no binding. Its spelling
|
|
707
|
+
// must not connect otherwise independent statements (Go `_ = 0`).
|
|
708
|
+
// Keep all invocation/RHS tokens: discarding a result does not erase
|
|
709
|
+
// the call that produces it.
|
|
710
|
+
const dependencyNames = candidate => [...identifiersOf(candidate)]
|
|
711
|
+
.filter(name => !(traits.discardIdentifierNames || []).includes(name));
|
|
712
|
+
const contextFacts = contextStatements.map(statement => ({
|
|
713
|
+
names: dependencyNames(statement), tokens: tokensOf(statement),
|
|
714
|
+
}));
|
|
715
|
+
const statementsByName = new Map();
|
|
716
|
+
contextFacts.forEach((fact, position) => {
|
|
717
|
+
for (const name of fact.names) {
|
|
718
|
+
if (!statementsByName.has(name)) statementsByName.set(name, []);
|
|
719
|
+
statementsByName.get(name).push(position);
|
|
720
|
+
}
|
|
721
|
+
});
|
|
722
|
+
// Checker comment position is measured against code tokens, so blank
|
|
723
|
+
// lines and ordinary comments do not change the call's applicability.
|
|
724
|
+
const sourceTokens = [];
|
|
725
|
+
const collectTokens = candidate => {
|
|
726
|
+
if (!candidate || COMMENT_NODES.has(candidate.type) ||
|
|
727
|
+
commentSpans.has(`${candidate.startIndex}:${candidate.endIndex}`)) return;
|
|
728
|
+
if (candidate.childCount === 0) { sourceTokens.push(candidate); return; }
|
|
729
|
+
for (const child of candidate.children) collectTokens(child);
|
|
730
|
+
};
|
|
731
|
+
if (comments.length) collectTokens(body);
|
|
732
|
+
const tokenPosition = offset => {
|
|
733
|
+
let low = 0;
|
|
734
|
+
let high = sourceTokens.length;
|
|
735
|
+
while (low < high) {
|
|
736
|
+
const mid = (low + high) >>> 1;
|
|
737
|
+
if (sourceTokens[mid].startIndex < offset) low = mid + 1;
|
|
738
|
+
else high = mid;
|
|
739
|
+
}
|
|
740
|
+
return low;
|
|
741
|
+
};
|
|
742
|
+
const commentFacts = comments.map(comment => {
|
|
743
|
+
const at = tokenPosition(comment.startIndex);
|
|
744
|
+
return [comment.text, at,
|
|
745
|
+
sourceTokens[at - 1]?.endPosition.row === comment.startPosition.row,
|
|
746
|
+
sourceTokens[at]?.startPosition.row === comment.endPosition.row];
|
|
747
|
+
});
|
|
748
|
+
const invocationContext = JSON.stringify([invocationTokens(declaration), commentFacts]);
|
|
749
|
+
for (const call of outgoingCalls) {
|
|
750
|
+
// A call's own tokens do not establish its meaning. Follow the
|
|
751
|
+
// written bindings of its callee, receiver and arguments (including
|
|
752
|
+
// aliases), and retain the checker comments around it.
|
|
753
|
+
const names = dependencyNames(call.node);
|
|
754
|
+
const seenNames = new Set(names);
|
|
755
|
+
const selected = new Set();
|
|
756
|
+
for (let at = 0; at < names.length; at++) {
|
|
757
|
+
for (const position of statementsByName.get(names[at]) || []) {
|
|
758
|
+
if (selected.has(position)) continue;
|
|
759
|
+
selected.add(position);
|
|
760
|
+
for (const name of contextFacts[position].names) {
|
|
761
|
+
if (!seenNames.has(name)) { seenNames.add(name); names.push(name); }
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
const nesting = [];
|
|
766
|
+
for (let parent = call.node.parent; parent && !sameNode(parent, body); parent = parent.parent) {
|
|
767
|
+
nesting.push(parent.type);
|
|
768
|
+
}
|
|
769
|
+
call.invocationKey = JSON.stringify(invocationTokens(call.node));
|
|
770
|
+
const relevantParameters = (parameters?.namedChildren || []).filter(parameter =>
|
|
771
|
+
dependencyNames(parameter).some(name => seenNames.has(name)));
|
|
772
|
+
const returnsValue = sameNode(call.node, body) || nesting.some(type => RESULT_NODES.has(type));
|
|
773
|
+
call.key = JSON.stringify([tokensOf(call.node), relevantParameters.map(parameter => tokensOf(parameter)),
|
|
774
|
+
returnsValue ? symbol.returnType || null : null, nesting,
|
|
775
|
+
[...selected].sort((a, b) => a - b).map(position => contextFacts[position].tokens),
|
|
776
|
+
commentFacts.map(([text, at, prior, next]) => [text, at - tokenPosition(call.node.startIndex), prior, next])]);
|
|
777
|
+
delete call.node;
|
|
778
|
+
}
|
|
779
|
+
const contract = { header: JSON.stringify(header), generator, returnForm, inferred, derivation, memberBindings };
|
|
780
|
+
Object.defineProperties(contract, {
|
|
781
|
+
outgoingCalls: { value: outgoingCalls }, invocationContext: { value: invocationContext },
|
|
782
|
+
});
|
|
783
|
+
return contract;
|
|
663
784
|
}
|
|
664
785
|
|
|
665
786
|
/**
|
|
@@ -680,7 +801,20 @@ function classifyChange(before, after, language) {
|
|
|
680
801
|
reasons.push('inferred-return-contract');
|
|
681
802
|
}
|
|
682
803
|
if ((before.memberBindings || null) !== (after.memberBindings || null)) reasons.push('inferred-member-type');
|
|
683
|
-
|
|
804
|
+
const change = { kind: reasons.length > 0 ? 'contract' : 'inert', reasons };
|
|
805
|
+
// Moving or formatting a call does not make it a new call. Repeated
|
|
806
|
+
// calls are counted, so an extra occurrence is still checked.
|
|
807
|
+
const prior = new Map();
|
|
808
|
+
for (const call of before.outgoingCalls || []) prior.set(call.key, (prior.get(call.key) || 0) + 1);
|
|
809
|
+
const preserved = new Set((before.outgoingCalls || []).map(call => call.invocationKey));
|
|
810
|
+
const changedCalls = (after.outgoingCalls || []).filter(call => {
|
|
811
|
+
const left = prior.get(call.key) || 0;
|
|
812
|
+
if (left) { prior.set(call.key, left - 1); return false; }
|
|
813
|
+
call.stableInvocation = before.invocationContext === after.invocationContext && preserved.has(call.invocationKey);
|
|
814
|
+
return true;
|
|
815
|
+
});
|
|
816
|
+
Object.defineProperty(change, 'changedCalls', { value: changedCalls });
|
|
817
|
+
return change;
|
|
684
818
|
}
|
|
685
819
|
|
|
686
820
|
/** The declaration in the base revision that a changed one continues. */
|
|
@@ -821,6 +955,90 @@ function removedFrom(before, after) {
|
|
|
821
955
|
return removed;
|
|
822
956
|
}
|
|
823
957
|
|
|
958
|
+
/** Checker comments carry semantics even when no declaration changed. */
|
|
959
|
+
function checkingContext(root, traits) {
|
|
960
|
+
if (!root || !traits.checkDirectiveComment) return null;
|
|
961
|
+
const all = root.descendantsOfType([...COMMENT_NODES]);
|
|
962
|
+
const comments = all.filter(node => traits.checkDirectiveComment(node.text));
|
|
963
|
+
if (comments.length === 0) return null;
|
|
964
|
+
const barriers = all.filter(node => !traits.checkDirectiveComment(node.text) && traits.checkDirectiveBarrier?.(node.text));
|
|
965
|
+
const spans = new Set(all.map(node => `${node.startIndex}:${node.endIndex}`));
|
|
966
|
+
const code = [];
|
|
967
|
+
const scopeCounts = new Map();
|
|
968
|
+
const walk = (node, scope = []) => {
|
|
969
|
+
if (spans.has(`${node.startIndex}:${node.endIndex}`)) return;
|
|
970
|
+
if (CALLABLE_NODES.has(node.type) || ['class', 'class_declaration', 'class_definition'].includes(node.type)) {
|
|
971
|
+
const body = node.childForFieldName('body');
|
|
972
|
+
if (body) {
|
|
973
|
+
const header = tokensOf(node, body);
|
|
974
|
+
const key = JSON.stringify([scope, node.type, header]);
|
|
975
|
+
const ordinal = scopeCounts.get(key) || 0;
|
|
976
|
+
scopeCounts.set(key, ordinal + 1);
|
|
977
|
+
scope = [...scope, [node.type, header, ordinal]];
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
if (node.childCount === 0) code.push({ node, scope });
|
|
981
|
+
else for (const child of node.children) walk(child, scope);
|
|
982
|
+
};
|
|
983
|
+
walk(root);
|
|
984
|
+
// Identify the neighboring code by its line tokens and declaration,
|
|
985
|
+
// not by an absolute token offset. An inert edit in an earlier body
|
|
986
|
+
// cannot move a later directive onto different code. Repeated equal
|
|
987
|
+
// lines retain an occurrence number, so moving a suppression between
|
|
988
|
+
// identical calls still changes its attachment.
|
|
989
|
+
const lines = [];
|
|
990
|
+
for (const entry of code) {
|
|
991
|
+
let line = lines[lines.length - 1];
|
|
992
|
+
if (!line || line.row !== entry.node.startPosition.row) {
|
|
993
|
+
line = { row: entry.node.startPosition.row, entries: [] };
|
|
994
|
+
lines.push(line);
|
|
995
|
+
}
|
|
996
|
+
entry.line = line;
|
|
997
|
+
entry.column = line.entries.length;
|
|
998
|
+
line.entries.push(entry);
|
|
999
|
+
}
|
|
1000
|
+
const lineCounts = new Map();
|
|
1001
|
+
for (const line of lines) {
|
|
1002
|
+
line.key = JSON.stringify(line.entries.map(({ node, scope }) => [node.type, node.text, scope]));
|
|
1003
|
+
line.ordinal = lineCounts.get(line.key) || 0;
|
|
1004
|
+
lineCounts.set(line.key, line.ordinal + 1);
|
|
1005
|
+
}
|
|
1006
|
+
const anchor = entry => entry ? [entry.line.key, entry.line.ordinal, entry.column] : null;
|
|
1007
|
+
const position = node => {
|
|
1008
|
+
let low = 0;
|
|
1009
|
+
let high = code.length;
|
|
1010
|
+
while (low < high) {
|
|
1011
|
+
const middle = (low + high) >>> 1;
|
|
1012
|
+
if (code[middle].node.startIndex < node.startIndex) low = middle + 1;
|
|
1013
|
+
else high = middle;
|
|
1014
|
+
}
|
|
1015
|
+
const at = [anchor(code[low - 1]), anchor(code[low]),
|
|
1016
|
+
code[low - 1]?.node.endPosition.row === node.startPosition.row,
|
|
1017
|
+
code[low]?.node.startPosition.row === node.endPosition.row];
|
|
1018
|
+
return { at, nextRow: code[low]?.node.startPosition.row };
|
|
1019
|
+
};
|
|
1020
|
+
return JSON.stringify([
|
|
1021
|
+
comments.map(node => {
|
|
1022
|
+
const mode = traits.checkFileDirectiveComment?.(node.text) || 'local';
|
|
1023
|
+
const { at, nextRow } = position(node);
|
|
1024
|
+
let attachment;
|
|
1025
|
+
if (mode === 'column-zero-file') attachment = node.startPosition.column === 0;
|
|
1026
|
+
else if (mode === 'local' || mode === 'leading-or-local' && at[0] !== null) {
|
|
1027
|
+
if (traits.checkDirectiveTarget === 'next-line') {
|
|
1028
|
+
// A line directive applies forward. Earlier code is
|
|
1029
|
+
// not its target; a block comment matters only if it
|
|
1030
|
+
// occupies a line between the directive and its code.
|
|
1031
|
+
const blocked = barriers.some(barrier =>
|
|
1032
|
+
barrier.startPosition.row < nextRow &&
|
|
1033
|
+
barrier.endPosition.row > node.endPosition.row);
|
|
1034
|
+
attachment = [at[1], at[2], at[3], blocked];
|
|
1035
|
+
} else attachment = at;
|
|
1036
|
+
} else attachment = mode === 'file' || at[0] === null;
|
|
1037
|
+
return [node.text, mode, attachment];
|
|
1038
|
+
}),
|
|
1039
|
+
]);
|
|
1040
|
+
}
|
|
1041
|
+
|
|
824
1042
|
/**
|
|
825
1043
|
* What a change did to a module outside its declarations.
|
|
826
1044
|
*
|
|
@@ -852,6 +1070,7 @@ function moduleChange(before, after, traits) {
|
|
|
852
1070
|
const exported = explicit(before.fileEntry);
|
|
853
1071
|
return {
|
|
854
1072
|
removedNames,
|
|
1073
|
+
checkingChanged: checkingContext(before.root, traits) !== checkingContext(after.root, traits),
|
|
855
1074
|
removedExports: removedNames.filter(name => exported.has(name)),
|
|
856
1075
|
// The module passed on everything another module exports, and no
|
|
857
1076
|
// longer does: which names that took away is not written here.
|
package/core/check.js
CHANGED
|
@@ -21,10 +21,13 @@
|
|
|
21
21
|
* blocked a break was observed: a confirmed call site that no longer
|
|
22
22
|
* fits the signature, an import of a name its module no
|
|
23
23
|
* longer has, a bare call nothing is left to answer.
|
|
24
|
-
* review
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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.
|
|
28
31
|
*
|
|
29
32
|
* TRUST is the worst verdict among the items. BLOCKED means a definite
|
|
30
33
|
* break, or that the check itself could not run. It never means "there is
|
|
@@ -35,14 +38,15 @@
|
|
|
35
38
|
|
|
36
39
|
const { diffImpact, composeAccount, callNotResolvedEntries } = require('./analysis');
|
|
37
40
|
const { providedNames } = require('./change-kinds');
|
|
41
|
+
const { outgoingChecker } = require('./outgoing-check');
|
|
38
42
|
const fs = require('fs');
|
|
39
43
|
const path = require('path');
|
|
40
44
|
const { isValueDefinition, isTestPath } = require('./shared');
|
|
41
45
|
const { langTraits } = require('../languages');
|
|
42
46
|
const { assessDeletion, definiteDependentsOf, REASON_TEXT } = require('./deletion');
|
|
43
|
-
const { occurrencesOutsideChange, captureHazards, shapeChangeOf, literalsLackingMember, HAZARD_TEXT } = require('./capture');
|
|
47
|
+
const { occurrencesOutsideChange, captureHazards, shapeChangeOf, literalsLackingMember, newOwnerUnreached, HAZARD_TEXT } = require('./capture');
|
|
44
48
|
const { reviewItemsOf } = require('./review-items');
|
|
45
|
-
const { createTestModel, linkFiles, commandsFor } = require('./test-units');
|
|
49
|
+
const { createTestModel, linkFiles, commandsFor, testSiteKey } = require('./test-units');
|
|
46
50
|
// Reasons a file has no command that are about the file, not the runner.
|
|
47
51
|
const FILE_WOULD_NOT_RUN = new Set(['declared-indirectly', 'collection-switched-off', 'conditional-tests']);
|
|
48
52
|
const { resolveTarget, flaggedPaths } = require('./snapshot');
|
|
@@ -65,6 +69,7 @@ const MODULE_TEXT = Object.freeze({
|
|
|
65
69
|
'no-longer-provided': 'other files import a name from this module that it no longer provides',
|
|
66
70
|
'import-removed-still-used': 'an import was removed and the file still uses the name it bound',
|
|
67
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',
|
|
68
73
|
'module-level-code': 'code outside any declaration changed; what runs when the module loads is not modeled',
|
|
69
74
|
'name-in-use': 'a new import binds a name the file already used',
|
|
70
75
|
'downstream-exposure': 'a name left the exports of a library: consumers outside the repository may use it',
|
|
@@ -167,7 +172,10 @@ function callersOf(index, name, definition) {
|
|
|
167
172
|
*/
|
|
168
173
|
function assessAddition(index, entry, definition, addedLinesByFile, account, judge, nonCallable = false) {
|
|
169
174
|
if (!definition) return { verdict: 'review', reasons: ['declaration-not-indexed'] };
|
|
170
|
-
const
|
|
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);
|
|
171
179
|
const summary = summarizeAccount(account);
|
|
172
180
|
const reasons = [];
|
|
173
181
|
if (occurrences.outside.length > 0) reasons.push('name-in-use');
|
|
@@ -182,7 +190,6 @@ function assessAddition(index, entry, definition, addedLinesByFile, account, jud
|
|
|
182
190
|
try {
|
|
183
191
|
contract = require('./deadcode').contractFactsOf(index, definition, index.files.get(definition.file)?.language);
|
|
184
192
|
} catch (_) { contract = null; }
|
|
185
|
-
const ownerExisted = ownerExistedBefore(index, definition, addedLinesByFile);
|
|
186
193
|
let shape;
|
|
187
194
|
try { shape = ownerExisted ? shapeChangeOf(index, definition, addedLinesByFile) : null; } catch (_) { shape = 'shape'; }
|
|
188
195
|
const hazards = captureHazards(index, definition, { ownerExisted, contract, shield, nonCallable, shape });
|
|
@@ -403,6 +410,7 @@ function assessModuleChange(index, moved, judge) {
|
|
|
403
410
|
// Loose code in a test file is the test: nothing imports it for
|
|
404
411
|
// what it does when it loads.
|
|
405
412
|
if (moved.codeChanged && !isTestPath(moved.relativePath)) reasons.push('module-level-code');
|
|
413
|
+
if (moved.checkingChanged) reasons.push('checking-context-changed');
|
|
406
414
|
if ((moved.shadowed || []).length > 0) reasons.push('name-in-use');
|
|
407
415
|
return {
|
|
408
416
|
verdict: reasons.length > 0 ? 'review' : 'ready', reasons,
|
|
@@ -686,6 +694,7 @@ function check(live, options = {}) {
|
|
|
686
694
|
}
|
|
687
695
|
throw e;
|
|
688
696
|
}
|
|
697
|
+
target.index._beginOp();
|
|
689
698
|
try {
|
|
690
699
|
const result = checkTarget(target.index, options, target.repoRoot);
|
|
691
700
|
const notes = [...target.notes];
|
|
@@ -703,6 +712,7 @@ function check(live, options = {}) {
|
|
|
703
712
|
if (notes.length > 0 && result && result.ok !== false) result.notes = notes;
|
|
704
713
|
return result;
|
|
705
714
|
} finally {
|
|
715
|
+
target.index._endOp();
|
|
706
716
|
target.dispose();
|
|
707
717
|
}
|
|
708
718
|
}
|
|
@@ -848,6 +858,26 @@ function checkTarget(index, options, repoRoot) {
|
|
|
848
858
|
const changed = limit ? allChanged.slice(0, limit) : allChanged;
|
|
849
859
|
|
|
850
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
|
+
};
|
|
851
881
|
|
|
852
882
|
for (const fn of changed) {
|
|
853
883
|
const filePath = fn.relativePath || fn.file || '';
|
|
@@ -897,7 +927,7 @@ function checkTarget(index, options, repoRoot) {
|
|
|
897
927
|
item.verdict = 'review';
|
|
898
928
|
item.reasons = [valueDeclaration ? 'value-change' : 'declaration-change'];
|
|
899
929
|
}
|
|
900
|
-
items.push(item);
|
|
930
|
+
items.push(withOutgoing(item, fn));
|
|
901
931
|
continue;
|
|
902
932
|
}
|
|
903
933
|
|
|
@@ -920,7 +950,7 @@ function checkTarget(index, options, repoRoot) {
|
|
|
920
950
|
// The contract every caller relies on is untouched.
|
|
921
951
|
item.verdict = 'ready';
|
|
922
952
|
item.reasons = [];
|
|
923
|
-
items.push(item);
|
|
953
|
+
items.push(withOutgoing(item, fn));
|
|
924
954
|
continue;
|
|
925
955
|
}
|
|
926
956
|
// The base revision decides which call sites the changed
|
|
@@ -975,7 +1005,7 @@ function checkTarget(index, options, repoRoot) {
|
|
|
975
1005
|
}
|
|
976
1006
|
}
|
|
977
1007
|
item.reviewItems = reviewItemsOf(unverifiedCallers);
|
|
978
|
-
items.push(item);
|
|
1008
|
+
items.push(withOutgoing(item, fn));
|
|
979
1009
|
continue;
|
|
980
1010
|
}
|
|
981
1011
|
|
|
@@ -1010,7 +1040,7 @@ function checkTarget(index, options, repoRoot) {
|
|
|
1010
1040
|
try { isEntry = isEntry || !!adapter?.isEntryPoint?.(definition); } catch (_) { /* keep */ }
|
|
1011
1041
|
item.orphan = !isEntry;
|
|
1012
1042
|
}
|
|
1013
|
-
items.push(item);
|
|
1043
|
+
items.push(withOutgoing(item, fn));
|
|
1014
1044
|
}
|
|
1015
1045
|
|
|
1016
1046
|
// Deleted callables: the base revision's declaration, assessed against
|
|
@@ -1067,6 +1097,7 @@ function checkTarget(index, options, repoRoot) {
|
|
|
1067
1097
|
className: fn.className || undefined,
|
|
1068
1098
|
_callerCache: callerCache,
|
|
1069
1099
|
_testModel: model,
|
|
1100
|
+
_testSitesOnly: true,
|
|
1070
1101
|
});
|
|
1071
1102
|
} catch (e) {
|
|
1072
1103
|
t = null;
|
|
@@ -1074,12 +1105,14 @@ function checkTarget(index, options, repoRoot) {
|
|
|
1074
1105
|
for (const tf of (t && t.testFiles) || []) {
|
|
1075
1106
|
let held = linesByFile.get(tf.file);
|
|
1076
1107
|
if (!held) {
|
|
1077
|
-
held = { row: tf, lines: new
|
|
1108
|
+
held = { row: tf, lines: new Map(), linked: new Set() };
|
|
1078
1109
|
linesByFile.set(tf.file, held);
|
|
1079
1110
|
}
|
|
1080
1111
|
for (const name of tf.linkedFunctions || []) held.linked.add(name);
|
|
1081
1112
|
for (const match of tf.matches || []) {
|
|
1082
|
-
if (match.matchType === 'call' || match.matchType === 'test-case')
|
|
1113
|
+
if (match.matchType === 'call' || match.matchType === 'test-case') {
|
|
1114
|
+
held.lines.set(testSiteKey(match), match);
|
|
1115
|
+
}
|
|
1083
1116
|
}
|
|
1084
1117
|
}
|
|
1085
1118
|
}
|
|
@@ -1092,7 +1125,7 @@ function checkTarget(index, options, repoRoot) {
|
|
|
1092
1125
|
// no link stands in.
|
|
1093
1126
|
const linesOf = new Map();
|
|
1094
1127
|
for (const [rel, held] of linesByFile) {
|
|
1095
|
-
if (absoluteTest.has(rel)) linesOf.set(absoluteTest.get(rel), [...held.lines]);
|
|
1128
|
+
if (absoluteTest.has(rel)) linesOf.set(absoluteTest.get(rel), [...held.lines.values()]);
|
|
1096
1129
|
}
|
|
1097
1130
|
testFiles = [];
|
|
1098
1131
|
supportFiles = [];
|
|
@@ -52,7 +52,7 @@ const COMMAND_CONTRACTS = Object.freeze({
|
|
|
52
52
|
{ name: 'definition', when: 'Default or `type` is a symbol kind such as function/class.', answer: 'Matching indexed definitions with location and signature metadata.' },
|
|
53
53
|
{ name: 'type lookup', when: '`type=type`.', answer: 'Type-definition records rather than general name matches.' },
|
|
54
54
|
],
|
|
55
|
-
defaults: ['Substring matching unless `exact=true`.', '
|
|
55
|
+
defaults: ['Substring matching unless `exact=true`.', 'Test definitions included unless `includeTests=false`.', '`limit` is the single result cap; `withSource=true` attaches exact bodies.'],
|
|
56
56
|
truth: 'Results are exact records from the static symbol index; usage counts are static indexed occurrences.',
|
|
57
57
|
nonGoals: ['Text search inside comments or configuration.', 'Compiler overload resolution across external dependencies.'],
|
|
58
58
|
invalidCombinations: ['A missing name is rejected.'],
|
|
@@ -183,7 +183,7 @@ const COMMAND_CONTRACTS = Object.freeze({
|
|
|
183
183
|
target: 'Optional symbol/handle. Omitting it selects diff/precommit mode.',
|
|
184
184
|
modes: [
|
|
185
185
|
{ name: 'symbol', when: 'A symbol target is supplied; `base` names the revision whose declaration its call sites were written against.', answer: 'Argument-count compatibility, uncertain sites, and the caller account.' },
|
|
186
|
-
{ name: 'diff', when: 'No target is supplied; `base` or `staged` selects changes.', answer: 'A verdict per changed symbol by what the change did: blocked (a break was observed, every site listed), review (
|
|
186
|
+
{ name: 'diff', when: 'No target is supplied; `base` or `staged` selects changes.', answer: 'A verdict per changed symbol by what the change did: blocked (a break was observed, every site listed), review (change exposure or call requirements not settled, reason named) or ready (no unresolved exposure in the modeled checks, not compiler or runtime proof); TRUST is the worst of them. Affected tests.' },
|
|
187
187
|
],
|
|
188
188
|
defaults: ['Symbol mode checks static arity, not compiler type compatibility.', '`base` defaults to HEAD in both modes.', 'Diff mode: `staged` reads the staged tree from the git index, never the worktree; `strict` makes REVIEW_REQUIRED fail as BLOCKED does.'],
|
|
189
189
|
truth: 'Findings are static diagnostics with explicit uncertainty; a clean result is not a compiler/test pass.',
|