Skip to content

Commit cb69efc

Browse files
authored
Merge pull request #143 from code0-tech/feat/#142
Reference suggestions don't traverse union-typed properties
2 parents 7052e2d + 6019d9e commit cb69efc

7 files changed

Lines changed: 406 additions & 39 deletions

File tree

package-lock.json

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
"build": "vite build"
2424
},
2525
"devDependencies": {
26-
"@code0-tech/sagittarius-graphql-types": "0.0.0-experimental-2668386432-315fff7e9283580109793944cb24db116d9cc264",
26+
"@code0-tech/sagittarius-graphql-types": "0.0.0-experimental-2705887128-b8e126d1674035ed0f869775b533d7093e8aedbd",
2727
"@types/node": "^25.6.0",
2828
"@typescript/vfs": "^1.6.4",
2929
"typescript": "^6.0.3",
@@ -33,7 +33,7 @@
3333
"lossless-json": "^4.3.0"
3434
},
3535
"peerDependencies": {
36-
"@code0-tech/sagittarius-graphql-types": "0.0.0-experimental-2668386432-315fff7e9283580109793944cb24db116d9cc264",
36+
"@code0-tech/sagittarius-graphql-types": "0.0.0-experimental-2705887128-b8e126d1674035ed0f869775b533d7093e8aedbd",
3737
"@typescript/vfs": "^1.6.4",
3838
"typescript": "^5.9.3 || ^6.0.2",
3939
"lossless-json": "^4.3.0"

src/util/references.util.ts

Lines changed: 76 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,59 @@ export const isRecursiveType = (
295295
* // For type {user: {name: string, age: number}} with expectedType = string
296296
* // Returns: [{path: [{path: 'user'}, {path: 'name'}], type: stringType}]
297297
*/
298+
/**
299+
* Determines whether a candidate type matches an expected parameter type.
300+
*
301+
* A plain type matches when it is (non-nullably) assignable to the expected type.
302+
* A union matches when ANY of its constituents matches: a union such as
303+
* `string | { deep: string }` is not assignable to `string` as a whole, yet its
304+
* string branch makes the union key a valid suggestion for a string parameter.
305+
*
306+
* @param type - The candidate type (already non-nullable)
307+
* @param checker - TypeScript TypeChecker for type operations
308+
* @param expectedType - The target parameter type to match against
309+
* @returns True if the type (or one of its union branches) matches the expected type
310+
*/
311+
const matchesExpectedType = (
312+
type: ts.Type,
313+
checker: ts.TypeChecker,
314+
expectedType: ts.Type
315+
): boolean => {
316+
if (type.isUnion()) {
317+
return type.types.some((constituent) =>
318+
matchesExpectedType(checker.getNonNullableType(constituent), checker, expectedType)
319+
);
320+
}
321+
322+
return (
323+
(type.flags & ts.TypeFlags.Never) === 0 &&
324+
checker.isTypeAssignableTo(type, expectedType)
325+
);
326+
};
327+
328+
/**
329+
* Resolves the object types whose properties should be traversed for a candidate.
330+
*
331+
* For a plain object type this is simply the type itself. For a union it is each
332+
* object-typed branch (non-nullable), since getProperties() on the union only
333+
* exposes properties shared by every branch — the distinct keys of individual
334+
* object branches (e.g. `deep` in `string | { deep: string }`) would otherwise be
335+
* unreachable. Primitive branches are dropped.
336+
*
337+
* @param type - The candidate type (already non-nullable)
338+
* @param checker - TypeScript TypeChecker for type operations
339+
* @returns The object branches to traverse into
340+
*/
341+
const getObjectBranches = (type: ts.Type, checker: ts.TypeChecker): ts.Type[] => {
342+
if (type.isUnion()) {
343+
return type.types
344+
.map((constituent) => checker.getNonNullableType(constituent))
345+
.filter(isRealObjectType);
346+
}
347+
348+
return isRealObjectType(type) ? [type] : [];
349+
};
350+
298351
const extractObjectProperties = (
299352
type: ts.Type,
300353
checker: ts.TypeChecker,
@@ -311,32 +364,41 @@ const extractObjectProperties = (
311364
// `string` parameter — strict assignability would reject it under strictNullChecks.
312365
// A purely nullish candidate strips down to `never` (assignable to anything),
313366
// so it must be excluded explicitly.
367+
//
368+
// A union candidate (e.g. `TEXT | { deep: TEXT }`) matches when ANY of its
369+
// constituents is assignable to the expected type: the whole union is not
370+
// assignable to `string`, but its string branch is, so the union key is still a
371+
// valid string suggestion.
314372
const nonNullableType = checker.getNonNullableType(type);
315-
if (
316-
(nonNullableType.flags & ts.TypeFlags.Never) === 0 &&
317-
checker.isTypeAssignableTo(nonNullableType, expectedType)
318-
) {
373+
if (matchesExpectedType(nonNullableType, checker, expectedType)) {
319374
results.push({ path: currentPath, type });
320375
}
321376

322377
// Recursively traverse into object properties. Traversal also runs on the
323378
// non-nullable type: a nullable object in the chain (e.g. `{bla?: TEXT} | null`)
324379
// is a union whose getProperties() is empty, which would cut off all nested paths.
380+
// Union candidates (e.g. `TEXT | { deep: TEXT }`) are traversed per object branch:
381+
// getProperties() on the union itself only exposes properties common to every
382+
// branch (none here), so `flexible.deep` would otherwise be unreachable.
325383
// Recursive data types (e.g. Order.delivery.order) are cut off via the visited
326384
// set — the checker caches type identities, so a cycle revisits the same ts.Type
327385
// object. The set only tracks the current branch (backtracked below) so the same
328386
// type may still appear on sibling paths. The depth cap only applies to types
329387
// that are part of a reference cycle (checked last, it's the expensive test);
330388
// purely nested non-recursive objects are traversed to arbitrary depth.
331-
if (
332-
isRealObjectType(nonNullableType) &&
333-
!visited.has(nonNullableType) &&
334-
(currentPath.length < MAX_REFERENCE_DEPTH ||
335-
!isRecursiveType(nonNullableType, checker, recursionCache))
336-
) {
337-
const properties = nonNullableType.getProperties();
389+
const objectBranches = getObjectBranches(nonNullableType, checker);
390+
objectBranches.forEach((branch) => {
391+
if (
392+
visited.has(branch) ||
393+
(currentPath.length >= MAX_REFERENCE_DEPTH &&
394+
isRecursiveType(branch, checker, recursionCache))
395+
) {
396+
return;
397+
}
398+
399+
const properties = branch.getProperties();
338400
if (properties && properties.length > 0) {
339-
visited.add(nonNullableType);
401+
visited.add(branch);
340402
properties.forEach((property) => {
341403
const propType = checker.getTypeOfSymbolAtLocation(property, property.valueDeclaration!);
342404
const propName = property.getName();
@@ -345,9 +407,9 @@ const extractObjectProperties = (
345407
// Recurse into nested properties
346408
results.push(...extractObjectProperties(propType, checker, expectedType, newPath, visited, recursionCache));
347409
});
348-
visited.delete(nonNullableType);
410+
visited.delete(branch);
349411
}
350-
}
412+
});
351413

352414
return results;
353415
};

src/utils.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,8 @@ export function generateFlowSourceCode(
113113
flow?: Flow,
114114
functions?: FunctionDefinition[],
115115
dataTypes?: DataType[],
116-
isForInference: boolean = false
116+
isForInference: boolean = false,
117+
assertNonNullReferences: boolean = true
117118
): string {
118119
const nodes = flow?.nodes?.nodes || [];
119120
const funcMap = new Map(functions?.map(f => [f.identifier, f]));
@@ -139,11 +140,15 @@ export function generateFlowSourceCode(
139140
ref.referencePath?.forEach(pathObj => {
140141
refCode += `?.${pathObj.path}`;
141142
});
142-
// Non-null assertion: a reference typed `string | null` (or an optional
143-
// chain like `node_X?.text`) must still satisfy a plain `string` parameter
144-
// under strictNullChecks — only the nullish part is waived, base type
145-
// mismatches still fail validation.
146-
return `/* @pos ${id} ${index} */ (${refCode})!`;
143+
// A reference may be nullable (`string | null`) or reach through optional
144+
// properties (an optional chain like `node_X?.text`). When `assertNonNullReferences`
145+
// is set (inference/schema), the nullish part is waived with a `!` so the base
146+
// type flows cleanly. During validation it is left in place so a possibly-null
147+
// reference feeding a non-null parameter surfaces a diagnostic — which is then
148+
// downgraded to a warning rather than being silently erased. Base type
149+
// mismatches still fail validation in both modes.
150+
const nonNull = assertNonNullReferences ? "!" : "";
151+
return `/* @pos ${id} ${index} */ (${refCode})${nonNull}`;
147152
}
148153
if (val.__typename === "LiteralValue") {
149154
const jsonString = val?.value !== null && val?.value !== undefined ? stringify(val?.value) : undefined

src/validation/getFlowValidation.ts

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,78 @@
1-
import {flattenDiagnosticMessageText} from "typescript";
1+
import ts, {flattenDiagnosticMessageText} from "typescript";
22
import {DataType, Flow, FunctionDefinition, NodeFunction} from "@code0-tech/sagittarius-graphql-types";
33
import {createCompilerHost, generateFlowSourceCode, ValidationResult} from "../utils";
44

5+
// TypeScript diagnostic codes we may soften into warnings for union-branch references.
6+
const TS_ARGUMENT_NOT_ASSIGNABLE = 2345; // "Argument of type X is not assignable to parameter of type Y."
7+
const TS_PROPERTY_DOES_NOT_EXIST = 2339; // "Property 'p' does not exist on type X."
8+
9+
/**
10+
* Finds the innermost AST node that fully contains the [start, end) span.
11+
*/
12+
const findInnermostNode = (node: ts.Node, start: number, end: number): ts.Node | undefined => {
13+
if (node.getStart() > start || node.getEnd() < end) return undefined;
14+
let result: ts.Node = node;
15+
node.forEachChild((child) => {
16+
const found = findInnermostNode(child, start, end);
17+
if (found) result = found;
18+
});
19+
return result;
20+
};
21+
22+
/**
23+
* Decides whether a type error stems from a reference whose value type is a union
24+
* where at least one branch would satisfy the expected type, but not all of them do:
25+
*
26+
* - a nullable reference — `TEXT | null` used for a plain TEXT parameter (the value
27+
* might be null/undefined at runtime); or
28+
* - a union-branch reference — `flexible: TEXT | { deep: TEXT }` used for a plain
29+
* TEXT parameter, or drilling into `flexible.deep` which only exists on the object
30+
* branch (the value might be the wrong branch at runtime).
31+
*
32+
* Both are references the schema engine offers as suggestions, so using them is a
33+
* warning rather than a hard error — the flow stays valid. Genuine mismatches where
34+
* no branch fits (e.g. `NUMBER | null` → TEXT) stay errors.
35+
*/
36+
const isSoftReferenceMismatch = (
37+
diagnostic: ts.Diagnostic,
38+
sourceFile: ts.SourceFile,
39+
checker: ts.TypeChecker
40+
): boolean => {
41+
if (diagnostic.start === undefined || diagnostic.length === undefined) return false;
42+
43+
const node = findInnermostNode(sourceFile, diagnostic.start, diagnostic.start + diagnostic.length);
44+
if (!node) return false;
45+
46+
// Argument not assignable: the argument's type is a union and at least one of its
47+
// non-nullish branches is assignable to the contextually expected parameter type.
48+
// Nullish branches are excluded so that `NUMBER | null` (no assignable base branch)
49+
// stays a hard error, while `TEXT | null` and `TEXT | { deep: TEXT }` soften.
50+
if (diagnostic.code === TS_ARGUMENT_NOT_ASSIGNABLE && ts.isExpression(node)) {
51+
const argType = checker.getTypeAtLocation(node);
52+
if (!argType.isUnion()) return false;
53+
54+
const expectedType = checker.getContextualType(node);
55+
if (!expectedType) return false;
56+
57+
return argType.types.some((branch) =>
58+
(branch.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined)) === 0 &&
59+
checker.isTypeAssignableTo(branch, expectedType)
60+
);
61+
}
62+
63+
// Property access into a union branch: the accessed object is a union and the
64+
// property exists on at least one of its branches.
65+
if (diagnostic.code === TS_PROPERTY_DOES_NOT_EXIST && ts.isPropertyAccessExpression(node.parent)) {
66+
const objectType = checker.getNonNullableType(checker.getTypeAtLocation(node.parent.expression));
67+
if (!objectType.isUnion()) return false;
68+
69+
const propertyName = node.getText();
70+
return objectType.types.some((branch) => branch.getProperty(propertyName) !== undefined);
71+
}
72+
73+
return false;
74+
};
75+
576
/**
677
* Validates a flow by generating virtual TypeScript code and running it through the TS compiler.
778
*/
@@ -58,14 +129,18 @@ export const getFlowValidation = (
58129
}
59130
}
60131

61-
const sourceCode = generateFlowSourceCode(flow, functions, dataTypes);
132+
// Validation keeps reference nullability (assertNonNullReferences = false) so that a
133+
// possibly-null reference into a non-null parameter surfaces as a diagnostic, which is
134+
// then downgraded to a warning below instead of being silently suppressed by a `!`.
135+
const sourceCode = generateFlowSourceCode(flow, functions, dataTypes, false, false);
62136

63137
// 3. Virtual TypeScript Compilation
64138
const fileName = "index.ts";
65139
const host = createCompilerHost(fileName, sourceCode);
66140
const sourceFile = host.getSourceFile(fileName)!;
67141

68142
const program = host.languageService.getProgram()!;
143+
const checker = program.getTypeChecker();
69144
const diagnostics = program.getSemanticDiagnostics(sourceFile);
70145

71146
const errors = diagnostics.map(d => {
@@ -113,10 +188,15 @@ export const getFlowValidation = (
113188
}
114189
}
115190

191+
// A nullable reference (`TEXT | null`) or a union-branch reference
192+
// (`TEXT | { deep: TEXT }`) into a non-null parameter is a valid suggestion, so its
193+
// mismatch is a warning rather than a hard error — the flow stays valid.
194+
const severity: "error" | "warning" = isSoftReferenceMismatch(d, sourceFile, checker) ? "warning" : "error";
195+
116196
return {
117197
message,
118198
code: d.code,
119-
severity: "error" as const,
199+
severity,
120200
nodeId,
121201
parameterIndex: typeof parameterIndex == "number" && Number.isSafeInteger(parameterIndex) ? parameterIndex : null,
122202
};

0 commit comments

Comments
 (0)