carrick 0.3.81 → 0.3.83
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/README.md +46 -19
- package/bin/carrick.mjs +45 -2
- package/dist/contract.d.ts +17 -0
- package/dist/contract.js.map +1 -1
- package/dist/hook/apply-patch.d.ts +13 -0
- package/dist/hook/apply-patch.js +100 -0
- package/dist/hook/apply-patch.js.map +1 -0
- package/dist/hook/post-edit.d.ts +30 -1
- package/dist/hook/post-edit.js +95 -24
- package/dist/hook/post-edit.js.map +1 -1
- package/dist/hook/reuse.d.ts +97 -0
- package/dist/hook/reuse.js +245 -0
- package/dist/hook/reuse.js.map +1 -0
- package/dist/hook/stop.d.ts +3 -0
- package/dist/hook/stop.js +76 -0
- package/dist/hook/stop.js.map +1 -0
- package/dist/hook/user-prompt.d.ts +9 -0
- package/dist/hook/user-prompt.js +79 -0
- package/dist/hook/user-prompt.js.map +1 -0
- package/dist/init/codex.d.ts +51 -0
- package/dist/init/codex.js +167 -0
- package/dist/init/codex.js.map +1 -0
- package/dist/init/connect.d.ts +13 -0
- package/dist/init/connect.js +21 -15
- package/dist/init/connect.js.map +1 -1
- package/dist/init/doctor.d.ts +36 -0
- package/dist/init/doctor.js +97 -2
- package/dist/init/doctor.js.map +1 -1
- package/dist/init/files.d.ts +16 -0
- package/dist/init/files.js +35 -0
- package/dist/init/files.js.map +1 -0
- package/dist/init/outdated.d.ts +54 -0
- package/dist/init/outdated.js +175 -0
- package/dist/init/outdated.js.map +1 -0
- package/dist/init/output.d.ts +35 -3
- package/dist/init/output.js +115 -23
- package/dist/init/output.js.map +1 -1
- package/dist/init/projects.d.ts +27 -13
- package/dist/init/projects.js +48 -50
- package/dist/init/projects.js.map +1 -1
- package/dist/init/remove.d.ts +0 -2
- package/dist/init/remove.js +91 -23
- package/dist/init/remove.js.map +1 -1
- package/dist/init/repos.d.ts +34 -0
- package/dist/init/repos.js +77 -0
- package/dist/init/repos.js.map +1 -1
- package/dist/init/run.d.ts +73 -6
- package/dist/init/run.js +382 -101
- package/dist/init/run.js.map +1 -1
- package/dist/init/settings.d.ts +20 -0
- package/dist/init/settings.js +42 -4
- package/dist/init/settings.js.map +1 -1
- package/dist/init/task-skills.d.ts +97 -0
- package/dist/init/task-skills.js +267 -0
- package/dist/init/task-skills.js.map +1 -0
- package/dist/init/workspace-file.d.ts +73 -0
- package/dist/init/workspace-file.js +173 -0
- package/dist/init/workspace-file.js.map +1 -0
- package/dist/scan.d.ts +13 -5
- package/dist/scan.js +21 -10
- package/dist/scan.js.map +1 -1
- package/dist/templates.d.ts +9 -0
- package/dist/templates.js +9 -1
- package/dist/templates.js.map +1 -1
- package/package.json +6 -6
- package/plugin/hooks/hooks.json +11 -0
- package/sidecar/dist/src/capture/check-classify.d.ts +10 -1
- package/sidecar/dist/src/capture/check-classify.js +66 -8
- package/sidecar/dist/src/capture/check-deep.d.ts +16 -4
- package/sidecar/dist/src/capture/check-deep.js +21 -17
- package/sidecar/dist/src/capture/check-fields.d.ts +83 -0
- package/sidecar/dist/src/capture/check-fields.js +259 -0
- package/sidecar/dist/src/capture/check-probe.d.ts +21 -1
- package/sidecar/dist/src/capture/check-probe.js +39 -0
- package/sidecar/dist/src/capture/check.js +9 -2
- package/templates/skills/carrick-census.md +89 -0
- package/templates/skills/carrick-drift.md +107 -0
- package/templates/skills/carrick-impact.md +108 -0
- package/templates/skills/carrick-reuse.md +106 -0
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* changes because of it, so a scan's verdicts are identical with and without
|
|
16
16
|
* it — what changes is whether a reader is told the verdict is a fact.
|
|
17
17
|
*/
|
|
18
|
+
import ts from 'typescript';
|
|
18
19
|
import type { TypeProvenance } from './api.js';
|
|
19
20
|
import type { ProbePlan } from './check-probe.js';
|
|
20
21
|
/** Deep findings for one pair, per probe side. */
|
|
@@ -22,11 +23,22 @@ export interface PairDeepFindings {
|
|
|
22
23
|
sent: TypeProvenance[];
|
|
23
24
|
expected: TypeProvenance[];
|
|
24
25
|
}
|
|
26
|
+
/** The one compiler program over the assembled probes, shared by every
|
|
27
|
+
* post-judge walk (fact-ness, and the field-level report the mismatch text
|
|
28
|
+
* names). Built once: two `createProgram` calls over the same file set would
|
|
29
|
+
* double the check phase's most expensive step and could not disagree usefully
|
|
30
|
+
* anyway. `undefined` when it cannot be built at all. */
|
|
31
|
+
export interface ProbeProgram {
|
|
32
|
+
program: ts.Program;
|
|
33
|
+
checker: ts.TypeChecker;
|
|
34
|
+
probesDir: string;
|
|
35
|
+
}
|
|
36
|
+
export declare function openProbeProgram(probesDir: string, plans: ProbePlan[]): ProbeProgram | undefined;
|
|
25
37
|
/**
|
|
26
38
|
* Walk both sides of every probe in the assembled workspace.
|
|
27
39
|
*
|
|
28
|
-
* Returns an empty map when the program
|
|
29
|
-
* must never be read as "clean", so the caller treats a missing entry
|
|
30
|
-
* unresolved rather than resolved.
|
|
40
|
+
* Returns an empty map when the program could not be built — absence of
|
|
41
|
+
* findings must never be read as "clean", so the caller treats a missing entry
|
|
42
|
+
* as unresolved rather than resolved.
|
|
31
43
|
*/
|
|
32
|
-
export declare function probeDeepFindings(
|
|
44
|
+
export declare function probeDeepFindings(opened: ProbeProgram | undefined, plans: ProbePlan[]): Map<string, PairDeepFindings>;
|
|
@@ -19,37 +19,41 @@ import ts from 'typescript';
|
|
|
19
19
|
import * as fs from 'node:fs';
|
|
20
20
|
import * as path from 'node:path';
|
|
21
21
|
import { findDisqualifyingTopTypes, provenanceOf } from './deep-walk.js';
|
|
22
|
-
|
|
23
|
-
* Walk both sides of every probe in the assembled workspace.
|
|
24
|
-
*
|
|
25
|
-
* Returns an empty map when the program cannot be built — absence of findings
|
|
26
|
-
* must never be read as "clean", so the caller treats a missing entry as
|
|
27
|
-
* unresolved rather than resolved.
|
|
28
|
-
*/
|
|
29
|
-
export function probeDeepFindings(probesDir, plans) {
|
|
30
|
-
const results = new Map();
|
|
22
|
+
export function openProbeProgram(probesDir, plans) {
|
|
31
23
|
if (plans.length === 0)
|
|
32
|
-
return
|
|
24
|
+
return undefined;
|
|
33
25
|
const configPath = path.join(probesDir, 'tsconfig.json');
|
|
34
26
|
if (!fs.existsSync(configPath))
|
|
35
|
-
return
|
|
36
|
-
let program;
|
|
27
|
+
return undefined;
|
|
37
28
|
try {
|
|
38
29
|
const raw = ts.readConfigFile(configPath, (f) => fs.readFileSync(f, 'utf8'));
|
|
39
30
|
if (raw.error)
|
|
40
|
-
return
|
|
31
|
+
return undefined;
|
|
41
32
|
const parsed = ts.parseJsonConfigFileContent(raw.config, ts.sys, probesDir);
|
|
42
33
|
const fileNames = plans
|
|
43
34
|
.map((plan) => path.join(probesDir, 'probes', plan.fileName))
|
|
44
35
|
.filter((f) => fs.existsSync(f));
|
|
45
36
|
if (fileNames.length === 0)
|
|
46
|
-
return
|
|
47
|
-
program = ts.createProgram(fileNames, { ...parsed.options, noEmit: true });
|
|
37
|
+
return undefined;
|
|
38
|
+
const program = ts.createProgram(fileNames, { ...parsed.options, noEmit: true });
|
|
39
|
+
return { program, checker: program.getTypeChecker(), probesDir };
|
|
48
40
|
}
|
|
49
41
|
catch {
|
|
50
|
-
return
|
|
42
|
+
return undefined;
|
|
51
43
|
}
|
|
52
|
-
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Walk both sides of every probe in the assembled workspace.
|
|
47
|
+
*
|
|
48
|
+
* Returns an empty map when the program could not be built — absence of
|
|
49
|
+
* findings must never be read as "clean", so the caller treats a missing entry
|
|
50
|
+
* as unresolved rather than resolved.
|
|
51
|
+
*/
|
|
52
|
+
export function probeDeepFindings(opened, plans) {
|
|
53
|
+
const results = new Map();
|
|
54
|
+
if (!opened)
|
|
55
|
+
return results;
|
|
56
|
+
const { program, checker, probesDir } = opened;
|
|
53
57
|
for (const plan of plans) {
|
|
54
58
|
const file = program.getSourceFile(path.join(probesDir, 'probes', plan.fileName));
|
|
55
59
|
if (!file)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Field-level report for a pair the judge called incompatible
|
|
3
|
+
* (carrick-tools/carrick-cloud#1118).
|
|
4
|
+
*
|
|
5
|
+
* `tsc` decides. Its elaboration names ONE field and then elides the rest
|
|
6
|
+
* ("Type 'A' is not assignable to type 'B'. Property 'x' is missing"), which is
|
|
7
|
+
* not enough for a reader deciding what to change: a create endpoint that
|
|
8
|
+
* requires `username` while the client sends `userName` reads as one missing
|
|
9
|
+
* property with no hint that the sent object carries a near-neighbour.
|
|
10
|
+
*
|
|
11
|
+
* This walk enumerates the differing fields of the SAME two types the judge
|
|
12
|
+
* compared, in the SAME program, using the compiler's own assignability
|
|
13
|
+
* relation. It is not a second judge:
|
|
14
|
+
* - it runs only after the bucket is decided and never changes one;
|
|
15
|
+
* - it never contradicts: a difference is only named when the checker itself
|
|
16
|
+
* says the two member types do not assign, and when it finds nothing it
|
|
17
|
+
* adds nothing and the raw tsc text stands alone;
|
|
18
|
+
* - it refuses the shapes where a member list is not an account of the type
|
|
19
|
+
* (a union root, a receiver with an index signature), rather than guessing
|
|
20
|
+
* about them.
|
|
21
|
+
*
|
|
22
|
+
* It reports one thing the judge structurally cannot: an optionality gap in the
|
|
23
|
+
* direction that still assigns (the sending side always provides a field the
|
|
24
|
+
* receiving side declares optional). That is a real drift between two sources
|
|
25
|
+
* — the receiver carries a branch that never runs — and no assignment error can
|
|
26
|
+
* exist for it. It is stated as an observation beside the verdict, never as the
|
|
27
|
+
* verdict.
|
|
28
|
+
*
|
|
29
|
+
* Seam: node builtins + `typescript` + this bundle only.
|
|
30
|
+
*/
|
|
31
|
+
import type { ProbePlan, Side } from './check-probe.js';
|
|
32
|
+
import type { ProbeProgram } from './check-deep.js';
|
|
33
|
+
/** What kind of difference one field path carries. */
|
|
34
|
+
export type FieldDifferenceNature =
|
|
35
|
+
/** The receiving side declares it; the sending side has no such member. */
|
|
36
|
+
'missing_in_sent'
|
|
37
|
+
/** The sending side provides it; the receiving side declares no such member. */
|
|
38
|
+
| 'extra_in_sent'
|
|
39
|
+
/** Optional where it is sent, required where it is read. */
|
|
40
|
+
| 'optional_in_sent'
|
|
41
|
+
/** Always sent, optional where it is read (no assignment error can exist). */
|
|
42
|
+
| 'optional_in_expected'
|
|
43
|
+
/** Both declare it and the member types do not assign. */
|
|
44
|
+
| 'type_differs';
|
|
45
|
+
export interface FieldDifference {
|
|
46
|
+
/** Dotted member path from the compared root (`''` is the root itself). */
|
|
47
|
+
path: string;
|
|
48
|
+
nature: FieldDifferenceNature;
|
|
49
|
+
/** Printed member type on the sending side, for `type_differs`. */
|
|
50
|
+
sentText?: string;
|
|
51
|
+
/** Printed member type on the receiving side, for `type_differs`. */
|
|
52
|
+
expectedText?: string;
|
|
53
|
+
}
|
|
54
|
+
export interface PairFieldReport {
|
|
55
|
+
/** Named differences, capped and ordered deterministically. */
|
|
56
|
+
differences: FieldDifference[];
|
|
57
|
+
/** How many further differences were found beyond the cap. */
|
|
58
|
+
truncated: number;
|
|
59
|
+
/**
|
|
60
|
+
* Whether the sent type's JSON wire form differs from its declared form, so
|
|
61
|
+
* the comparison the reader is being shown is against the serialised shape
|
|
62
|
+
* (a `Date` compared as the string it serialises to).
|
|
63
|
+
*/
|
|
64
|
+
wireApplied: boolean;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Cap on named fields. A mismatch with more differing members than this is
|
|
68
|
+
* better described as two unrelated shapes than as a list, and the text says
|
|
69
|
+
* how many more there are rather than pretending the list is complete.
|
|
70
|
+
*/
|
|
71
|
+
export declare const MAX_NAMED_FIELDS = 8;
|
|
72
|
+
/**
|
|
73
|
+
* Field reports for every plan whose probe the program could read, keyed by
|
|
74
|
+
* pair id. A plan with no entry has no report, which is not a claim that its
|
|
75
|
+
* types agree.
|
|
76
|
+
*/
|
|
77
|
+
export declare function pairFieldReports(opened: ProbeProgram | undefined, plans: ProbePlan[]): Map<string, PairFieldReport>;
|
|
78
|
+
/**
|
|
79
|
+
* The sentence appended to a mismatch diagnostic. Names the two sides as
|
|
80
|
+
* producer and consumer (never the probe's internal sent/expected), so the
|
|
81
|
+
* reader knows which repo to change.
|
|
82
|
+
*/
|
|
83
|
+
export declare function describeFieldReport(report: PairFieldReport, sentSide: Side, expectedSide: Side): string;
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Field-level report for a pair the judge called incompatible
|
|
3
|
+
* (carrick-tools/carrick-cloud#1118).
|
|
4
|
+
*
|
|
5
|
+
* `tsc` decides. Its elaboration names ONE field and then elides the rest
|
|
6
|
+
* ("Type 'A' is not assignable to type 'B'. Property 'x' is missing"), which is
|
|
7
|
+
* not enough for a reader deciding what to change: a create endpoint that
|
|
8
|
+
* requires `username` while the client sends `userName` reads as one missing
|
|
9
|
+
* property with no hint that the sent object carries a near-neighbour.
|
|
10
|
+
*
|
|
11
|
+
* This walk enumerates the differing fields of the SAME two types the judge
|
|
12
|
+
* compared, in the SAME program, using the compiler's own assignability
|
|
13
|
+
* relation. It is not a second judge:
|
|
14
|
+
* - it runs only after the bucket is decided and never changes one;
|
|
15
|
+
* - it never contradicts: a difference is only named when the checker itself
|
|
16
|
+
* says the two member types do not assign, and when it finds nothing it
|
|
17
|
+
* adds nothing and the raw tsc text stands alone;
|
|
18
|
+
* - it refuses the shapes where a member list is not an account of the type
|
|
19
|
+
* (a union root, a receiver with an index signature), rather than guessing
|
|
20
|
+
* about them.
|
|
21
|
+
*
|
|
22
|
+
* It reports one thing the judge structurally cannot: an optionality gap in the
|
|
23
|
+
* direction that still assigns (the sending side always provides a field the
|
|
24
|
+
* receiving side declares optional). That is a real drift between two sources
|
|
25
|
+
* — the receiver carries a branch that never runs — and no assignment error can
|
|
26
|
+
* exist for it. It is stated as an observation beside the verdict, never as the
|
|
27
|
+
* verdict.
|
|
28
|
+
*
|
|
29
|
+
* Seam: node builtins + `typescript` + this bundle only.
|
|
30
|
+
*/
|
|
31
|
+
import ts from 'typescript';
|
|
32
|
+
/**
|
|
33
|
+
* Cap on named fields. A mismatch with more differing members than this is
|
|
34
|
+
* better described as two unrelated shapes than as a list, and the text says
|
|
35
|
+
* how many more there are rather than pretending the list is complete.
|
|
36
|
+
*/
|
|
37
|
+
export const MAX_NAMED_FIELDS = 8;
|
|
38
|
+
/** Printed member types are for reading, not for re-parsing. */
|
|
39
|
+
const MAX_TYPE_TEXT = 80;
|
|
40
|
+
/** Depth cap on the structural descent. Deeper differences are reported at the
|
|
41
|
+
* deepest ancestor the walk reached, never dropped. */
|
|
42
|
+
const MAX_FIELD_DEPTH = 4;
|
|
43
|
+
function assignabilityOf(checker) {
|
|
44
|
+
const fn = checker.isTypeAssignableTo;
|
|
45
|
+
return typeof fn === 'function' ? fn.bind(checker) : undefined;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Field reports for every plan whose probe the program could read, keyed by
|
|
49
|
+
* pair id. A plan with no entry has no report, which is not a claim that its
|
|
50
|
+
* types agree.
|
|
51
|
+
*/
|
|
52
|
+
export function pairFieldReports(opened, plans) {
|
|
53
|
+
const results = new Map();
|
|
54
|
+
if (!opened)
|
|
55
|
+
return results;
|
|
56
|
+
const { program, checker, probesDir } = opened;
|
|
57
|
+
const isAssignableTo = assignabilityOf(checker);
|
|
58
|
+
if (!isAssignableTo)
|
|
59
|
+
return results;
|
|
60
|
+
for (const plan of plans) {
|
|
61
|
+
const file = program.getSourceFile(`${probesDir}/probes/${plan.fileName}`.split('\\').join('/'));
|
|
62
|
+
const source = file ??
|
|
63
|
+
program
|
|
64
|
+
.getSourceFiles()
|
|
65
|
+
.find((sf) => sf.fileName.endsWith(`/probes/${plan.fileName}`));
|
|
66
|
+
if (!source)
|
|
67
|
+
continue;
|
|
68
|
+
// Whatever the judge's decisive assignment actually sent: the GraphQL
|
|
69
|
+
// comparand where one exists, then the JSON wire form where one exists.
|
|
70
|
+
// Reading `sent` there instead would describe a type the judge did not
|
|
71
|
+
// compare, which is the one way this walk could contradict it.
|
|
72
|
+
const declared = declaredConstType(source, checker, 'sentComparand') ??
|
|
73
|
+
declaredConstType(source, checker, 'sent');
|
|
74
|
+
const expected = declaredConstType(source, checker, 'expected');
|
|
75
|
+
if (!declared || !expected)
|
|
76
|
+
continue;
|
|
77
|
+
const wire = declaredConstType(source, checker, 'sentWire');
|
|
78
|
+
const compared = wire ?? declared;
|
|
79
|
+
const report = diffReport(compared.type, expected.type, checker, isAssignableTo, expected.node);
|
|
80
|
+
report.wireApplied =
|
|
81
|
+
wire !== undefined &&
|
|
82
|
+
!(isAssignableTo(wire.type, declared.type) && isAssignableTo(declared.type, wire.type));
|
|
83
|
+
results.set(plan.pairId, report);
|
|
84
|
+
}
|
|
85
|
+
return results;
|
|
86
|
+
}
|
|
87
|
+
/** The type of one of the probe's declared consts, read where it is declared. */
|
|
88
|
+
function declaredConstType(file, checker, name) {
|
|
89
|
+
for (const statement of file.statements) {
|
|
90
|
+
if (!ts.isVariableStatement(statement))
|
|
91
|
+
continue;
|
|
92
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
93
|
+
if (!ts.isIdentifier(declaration.name) || declaration.name.text !== name)
|
|
94
|
+
continue;
|
|
95
|
+
const type = checker.getTypeAtLocation(declaration.name);
|
|
96
|
+
if (!type)
|
|
97
|
+
return undefined;
|
|
98
|
+
return { type, node: declaration.name };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
function diffReport(sent, expected, checker, isAssignableTo, at) {
|
|
104
|
+
const found = [];
|
|
105
|
+
walk(sent, expected, '', 0, { checker, isAssignableTo, at, found });
|
|
106
|
+
found.sort((a, b) => a.path === b.path ? compareText(a.nature, b.nature) : compareText(a.path, b.path));
|
|
107
|
+
return {
|
|
108
|
+
differences: found.slice(0, MAX_NAMED_FIELDS),
|
|
109
|
+
truncated: Math.max(0, found.length - MAX_NAMED_FIELDS),
|
|
110
|
+
wireApplied: false,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function compareText(a, b) {
|
|
114
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* A shape whose members can be compared one by one without diverging from the
|
|
118
|
+
* whole-type relation.
|
|
119
|
+
*
|
|
120
|
+
* The object flag is the first and widest of these: a union or an intersection
|
|
121
|
+
* does not carry it, and neither does a primitive, so a root the judge compared
|
|
122
|
+
* as a whole is never taken apart into members one of its constituents happens
|
|
123
|
+
* to share.
|
|
124
|
+
*
|
|
125
|
+
* An index signature is excluded because it makes the member list an incomplete
|
|
126
|
+
* account of the type: a field the sender provides that the receiver's index
|
|
127
|
+
* signature accepts is not a field the receiver "declares no such field" for,
|
|
128
|
+
* and saying so would be false. Arrays and tuples carry a numeric one, so the
|
|
129
|
+
* same clause keeps `length` and `push` out of a field list; an element
|
|
130
|
+
* difference is reported at the field that holds the array.
|
|
131
|
+
*/
|
|
132
|
+
function isComparableObject(type, checker) {
|
|
133
|
+
if (type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.Never)) {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
if ((type.flags & ts.TypeFlags.Object) === 0)
|
|
137
|
+
return false;
|
|
138
|
+
if (checker.getIndexInfosOfType(type).length > 0)
|
|
139
|
+
return false;
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
function walk(sent, expected, path, depth, ctx) {
|
|
143
|
+
const { checker } = ctx;
|
|
144
|
+
if (!isComparableObject(sent, checker) || !isComparableObject(expected, checker)) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const sentProps = new Map(sent.getProperties().map((p) => [p.getName(), p]));
|
|
148
|
+
const expectedProps = new Map(expected.getProperties().map((p) => [p.getName(), p]));
|
|
149
|
+
/** Members the receiver declares and the sender has no member for, optional
|
|
150
|
+
* ones included. Only the REQUIRED ones are a difference; the rest still
|
|
151
|
+
* count here, because a receiver waiting on a member it never gets is what
|
|
152
|
+
* makes a sender-only member worth naming beside it. */
|
|
153
|
+
let absent = 0;
|
|
154
|
+
for (const [name, expectedProp] of expectedProps) {
|
|
155
|
+
const at = join(path, name);
|
|
156
|
+
const sentProp = sentProps.get(name);
|
|
157
|
+
if (!sentProp) {
|
|
158
|
+
absent += 1;
|
|
159
|
+
// An optional member the sender omits is what optional MEANS. Naming it
|
|
160
|
+
// would state that the receiver requires it, which is false, and it is
|
|
161
|
+
// not what the judge rejected the pair for.
|
|
162
|
+
if (!isOptional(expectedProp)) {
|
|
163
|
+
ctx.found.push({ path: at, nature: 'missing_in_sent' });
|
|
164
|
+
}
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const sentOptional = isOptional(sentProp);
|
|
168
|
+
const expectedOptional = isOptional(expectedProp);
|
|
169
|
+
if (sentOptional && !expectedOptional) {
|
|
170
|
+
ctx.found.push({ path: at, nature: 'optional_in_sent' });
|
|
171
|
+
}
|
|
172
|
+
else if (!sentOptional && expectedOptional) {
|
|
173
|
+
// No assignment error exists for this direction, which is exactly why
|
|
174
|
+
// the judge cannot report it and this walk must.
|
|
175
|
+
ctx.found.push({ path: at, nature: 'optional_in_expected' });
|
|
176
|
+
}
|
|
177
|
+
const sentType = memberType(sentProp, ctx);
|
|
178
|
+
const expectedType = memberType(expectedProp, ctx);
|
|
179
|
+
if (ctx.isAssignableTo(sentType, expectedType))
|
|
180
|
+
continue;
|
|
181
|
+
const sentInner = checker.getNonNullableType(sentType);
|
|
182
|
+
const expectedInner = checker.getNonNullableType(expectedType);
|
|
183
|
+
if (depth + 1 < MAX_FIELD_DEPTH &&
|
|
184
|
+
isComparableObject(sentInner, checker) &&
|
|
185
|
+
isComparableObject(expectedInner, checker)) {
|
|
186
|
+
walk(sentInner, expectedInner, at, depth + 1, ctx);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
ctx.found.push({
|
|
190
|
+
path: at,
|
|
191
|
+
nature: 'type_differs',
|
|
192
|
+
sentText: printType(sentType, ctx),
|
|
193
|
+
expectedText: printType(expectedType, ctx),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
// A field the sender provides that the receiver does not declare is normal
|
|
197
|
+
// (a response carrying more than a call site reads), so it is only worth
|
|
198
|
+
// naming beside a member the receiver is waiting on and does not get: that
|
|
199
|
+
// pairing is what a renamed or relocated field looks like from the outside.
|
|
200
|
+
// On the common subset case — a call site reading fewer fields than the
|
|
201
|
+
// producer returns — nothing is absent and nothing is named.
|
|
202
|
+
if (absent === 0)
|
|
203
|
+
return;
|
|
204
|
+
for (const [name] of sentProps) {
|
|
205
|
+
if (expectedProps.has(name))
|
|
206
|
+
continue;
|
|
207
|
+
ctx.found.push({ path: join(path, name), nature: 'extra_in_sent' });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function join(path, name) {
|
|
211
|
+
return path === '' ? name : `${path}.${name}`;
|
|
212
|
+
}
|
|
213
|
+
function isOptional(symbol) {
|
|
214
|
+
return (symbol.flags & ts.SymbolFlags.Optional) !== 0;
|
|
215
|
+
}
|
|
216
|
+
function memberType(symbol, ctx) {
|
|
217
|
+
return ctx.checker.getTypeOfSymbolAtLocation(symbol, ctx.at);
|
|
218
|
+
}
|
|
219
|
+
function printType(type, ctx) {
|
|
220
|
+
const text = ctx.checker.typeToString(type, undefined, ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.InTypeAlias);
|
|
221
|
+
const flat = text.replace(/\s+/g, ' ').trim();
|
|
222
|
+
return flat.length > MAX_TYPE_TEXT ? `${flat.slice(0, MAX_TYPE_TEXT - 1)}…` : flat;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* The sentence appended to a mismatch diagnostic. Names the two sides as
|
|
226
|
+
* producer and consumer (never the probe's internal sent/expected), so the
|
|
227
|
+
* reader knows which repo to change.
|
|
228
|
+
*/
|
|
229
|
+
export function describeFieldReport(report, sentSide, expectedSide) {
|
|
230
|
+
const parts = [];
|
|
231
|
+
if (report.wireApplied) {
|
|
232
|
+
parts.push(`The ${sentSide}'s type is compared in the form JSON puts on the wire: a value with a toJSON() method (a Date, for example) travels as what it serialises to.`);
|
|
233
|
+
}
|
|
234
|
+
if (report.differences.length > 0) {
|
|
235
|
+
const named = report.differences
|
|
236
|
+
.map((d) => describeDifference(d, sentSide, expectedSide))
|
|
237
|
+
.join('; ');
|
|
238
|
+
const more = report.truncated > 0
|
|
239
|
+
? `; and ${report.truncated} further field${report.truncated === 1 ? '' : 's'} differ${report.truncated === 1 ? 's' : ''} (${MAX_NAMED_FIELDS} named here)`
|
|
240
|
+
: '';
|
|
241
|
+
parts.push(`Fields that differ: ${named}${more}.`);
|
|
242
|
+
}
|
|
243
|
+
return parts.length === 0 ? '' : ` ${parts.join(' ')}`;
|
|
244
|
+
}
|
|
245
|
+
function describeDifference(difference, sentSide, expectedSide) {
|
|
246
|
+
const at = `'${difference.path}'`;
|
|
247
|
+
switch (difference.nature) {
|
|
248
|
+
case 'missing_in_sent':
|
|
249
|
+
return `${at} is required by the ${expectedSide} and the ${sentSide} does not send it`;
|
|
250
|
+
case 'extra_in_sent':
|
|
251
|
+
return `${at} is sent by the ${sentSide} and the ${expectedSide} declares no such field`;
|
|
252
|
+
case 'optional_in_sent':
|
|
253
|
+
return `${at} is optional on the ${sentSide} and required by the ${expectedSide}`;
|
|
254
|
+
case 'optional_in_expected':
|
|
255
|
+
return `${at} is always sent by the ${sentSide} and optional on the ${expectedSide}`;
|
|
256
|
+
case 'type_differs':
|
|
257
|
+
return `${at} is ${difference.sentText} on the ${sentSide} and ${difference.expectedText} on the ${expectedSide}`;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
@@ -7,6 +7,12 @@
|
|
|
7
7
|
* conditional-type relation diverges around `any`, and because the compiler's
|
|
8
8
|
* elaborated assignment error is the user-facing mismatch report.
|
|
9
9
|
*
|
|
10
|
+
* An `http` pair carries a SECOND assignment of the same value in the form JSON
|
|
11
|
+
* puts on the wire, and that one decides the bucket (see `wireAssignmentLine`):
|
|
12
|
+
* a payload is serialised before it travels, so the declared types are not what
|
|
13
|
+
* meet each other. Both questions go to the same judge; nothing here decides a
|
|
14
|
+
* verdict.
|
|
15
|
+
*
|
|
10
16
|
* GraphQL pairs additionally unwrap the producer's resolver-return ENVELOPE
|
|
11
17
|
* before the assignment (see the `graphql` branch in `buildProbe`): a GraphQL
|
|
12
18
|
* producer's captured type is the resolver function's return type with
|
|
@@ -59,9 +65,23 @@ export interface ProbePlan {
|
|
|
59
65
|
importLines: number[];
|
|
60
66
|
/** 1-based line -> gate name. TS2344 here => baked-any / unverifiable. */
|
|
61
67
|
gateLines: Map<number, GateName>;
|
|
62
|
-
/** 1-based line of the value-level assignment
|
|
68
|
+
/** 1-based line of the value-level assignment of the DECLARED sent type. */
|
|
63
69
|
assignmentLine: number;
|
|
70
|
+
/**
|
|
71
|
+
* 1-based line of the second assignment, which sends the same value in the
|
|
72
|
+
* form JSON puts on the wire (carrick-tools/carrick-cloud#1119). Present for
|
|
73
|
+
* `http` pairs, whose payload is serialised; absent for every other
|
|
74
|
+
* protocol, where the declared form is what travels.
|
|
75
|
+
*
|
|
76
|
+
* This is the DECISIVE line when present: the comparand short-circuits to
|
|
77
|
+
* the declared type whenever that already assigns, so an error here means
|
|
78
|
+
* the shapes disagree in both forms, and no error here means they agree in
|
|
79
|
+
* the form that actually travels.
|
|
80
|
+
*/
|
|
81
|
+
wireAssignmentLine?: number;
|
|
64
82
|
}
|
|
83
|
+
/** The assignment line whose diagnostic decides the bucket. */
|
|
84
|
+
export declare function decisiveAssignmentLine(plan: ProbePlan): number;
|
|
65
85
|
/**
|
|
66
86
|
* Build one probe, recording the exact line of every gate and the assignment so
|
|
67
87
|
* the classifier never depends on hard-coded offsets. `packageOf` maps a
|
|
@@ -7,6 +7,12 @@
|
|
|
7
7
|
* conditional-type relation diverges around `any`, and because the compiler's
|
|
8
8
|
* elaborated assignment error is the user-facing mismatch report.
|
|
9
9
|
*
|
|
10
|
+
* An `http` pair carries a SECOND assignment of the same value in the form JSON
|
|
11
|
+
* puts on the wire, and that one decides the bucket (see `wireAssignmentLine`):
|
|
12
|
+
* a payload is serialised before it travels, so the declared types are not what
|
|
13
|
+
* meet each other. Both questions go to the same judge; nothing here decides a
|
|
14
|
+
* verdict.
|
|
15
|
+
*
|
|
10
16
|
* GraphQL pairs additionally unwrap the producer's resolver-return ENVELOPE
|
|
11
17
|
* before the assignment (see the `graphql` branch in `buildProbe`): a GraphQL
|
|
12
18
|
* producer's captured type is the resolver function's return type with
|
|
@@ -63,6 +69,10 @@ export function pairId(spec) {
|
|
|
63
69
|
].join('|');
|
|
64
70
|
return fnv1a(key);
|
|
65
71
|
}
|
|
72
|
+
/** The assignment line whose diagnostic decides the bucket. */
|
|
73
|
+
export function decisiveAssignmentLine(plan) {
|
|
74
|
+
return plan.wireAssignmentLine ?? plan.assignmentLine;
|
|
75
|
+
}
|
|
66
76
|
/**
|
|
67
77
|
* Build one probe, recording the exact line of every gate and the assignment so
|
|
68
78
|
* the classifier never depends on hard-coded offsets. `packageOf` maps a
|
|
@@ -148,6 +158,34 @@ export function buildProbe(spec, packageOf) {
|
|
|
148
158
|
else {
|
|
149
159
|
assignmentLine = push(`const expected: Expected = sent;`);
|
|
150
160
|
}
|
|
161
|
+
// The JSON wire line (carrick-tools/carrick-cloud#1119). An `http` payload is
|
|
162
|
+
// serialised before it travels, and `JSON.stringify` writes a value's
|
|
163
|
+
// `toJSON()` RESULT: a producer's `Date` arrives at the consumer as the
|
|
164
|
+
// string it serialises to, so comparing the DECLARED `Date` against a
|
|
165
|
+
// correctly-declared `string` reports a drift that cannot happen. The
|
|
166
|
+
// transform is applied to the SENT side in BOTH directions, which is where
|
|
167
|
+
// serialisation happens: a consumer that sends a `Date` in a request body
|
|
168
|
+
// likewise delivers a string, so a producer declaring `Date` there is a real
|
|
169
|
+
// mismatch and stays one.
|
|
170
|
+
//
|
|
171
|
+
// `WireSent` short-circuits to the declared type when that already assigns,
|
|
172
|
+
// so a pair that agrees as declared never instantiates the mapped type (no
|
|
173
|
+
// cost, and no way for the transform to turn an agreeing pair into a
|
|
174
|
+
// disagreeing one). tsc stays the judge of both forms.
|
|
175
|
+
let wireAssignmentLine;
|
|
176
|
+
if (spec.protocol === 'http') {
|
|
177
|
+
push(`type JsonWireDepth = [never, 0, 1, 2, 3, 4, 5, 6];`);
|
|
178
|
+
push(`type JsonWire<T, D extends number = 6> = [D] extends [never] ? T : T extends { toJSON: (...args: any[]) => infer R } ? JsonWire<R, JsonWireDepth[D]> : T extends (...args: any[]) => any ? T : T extends object ? { [K in keyof T]: JsonWire<T[K], JsonWireDepth[D]> } : T;`);
|
|
179
|
+
// Keep the DECLARED type whenever serialising changes nothing observable,
|
|
180
|
+
// so the compiler's headline still names the real surface alias (the probe
|
|
181
|
+
// prints `Sent`, which the scrub rewrites) instead of expanding a mapped
|
|
182
|
+
// type structurally. Only a pair whose payload really is transformed loses
|
|
183
|
+
// that name — and there the declared name no longer describes what travels.
|
|
184
|
+
push(`type JsonWireSame<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;`);
|
|
185
|
+
push(`type WireSent = [Sent] extends [Expected] ? Sent : (JsonWireSame<JsonWire<Sent>, Sent> extends true ? Sent : JsonWire<Sent>);`);
|
|
186
|
+
push(`declare const sentWire: WireSent;`);
|
|
187
|
+
wireAssignmentLine = push(`const expectedWire: Expected = sentWire;`);
|
|
188
|
+
}
|
|
151
189
|
return {
|
|
152
190
|
pairId: id,
|
|
153
191
|
spec,
|
|
@@ -159,5 +197,6 @@ export function buildProbe(spec, packageOf) {
|
|
|
159
197
|
importLines,
|
|
160
198
|
gateLines,
|
|
161
199
|
assignmentLine,
|
|
200
|
+
wireAssignmentLine,
|
|
162
201
|
};
|
|
163
202
|
}
|
|
@@ -24,7 +24,8 @@ import { classifyPair, parseTscOutput, } from './check-classify.js';
|
|
|
24
24
|
import { scrubPaths } from './check-scrub.js';
|
|
25
25
|
import { assembleWorkspace, writeProbes, } from './check-workspace.js';
|
|
26
26
|
import { buildPoisonIndexes } from './check-poison.js';
|
|
27
|
-
import { probeDeepFindings } from './check-deep.js';
|
|
27
|
+
import { openProbeProgram, probeDeepFindings } from './check-deep.js';
|
|
28
|
+
import { pairFieldReports } from './check-fields.js';
|
|
28
29
|
function runProcess(command, args, cwd) {
|
|
29
30
|
return new Promise((resolve, reject) => {
|
|
30
31
|
const child = spawn(command, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
@@ -279,7 +280,12 @@ export async function runCheck(opts, onProgress) {
|
|
|
279
280
|
// carries a member-level any/unknown HERE — after install, where the capture
|
|
280
281
|
// could not look (carrick#450). It sets `resolved` and nothing else; no
|
|
281
282
|
// bucket depends on it.
|
|
282
|
-
const
|
|
283
|
+
const probeProgram = openProbeProgram(ws.probesDir, probing);
|
|
284
|
+
const deepByPair = probeDeepFindings(probeProgram, probing);
|
|
285
|
+
// The same program answers which FIELDS differ on a pair the judge called
|
|
286
|
+
// incompatible (carrick-tools/carrick-cloud#1118). It names what the verdict
|
|
287
|
+
// is about; it never decides one.
|
|
288
|
+
const fieldsByPair = pairFieldReports(probeProgram, probing);
|
|
283
289
|
const verdicts = sortVerdicts([
|
|
284
290
|
...probing.map((plan) => classifyPair({
|
|
285
291
|
plan,
|
|
@@ -287,6 +293,7 @@ export async function runCheck(opts, onProgress) {
|
|
|
287
293
|
poisonReason,
|
|
288
294
|
scrubCtx,
|
|
289
295
|
deepFindings: deepByPair.get(plan.pairId),
|
|
296
|
+
fieldReport: fieldsByPair.get(plan.pairId),
|
|
290
297
|
})),
|
|
291
298
|
...preGated,
|
|
292
299
|
...unresolved,
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: carrick-census
|
|
3
|
+
description: Use for every place that does X questions, such as every service that reads this environment variable, every handler that checks a permission, or every client of this queue. Pages the Carrick intent index under two wordings and reports what it read and what it could not see.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Every place that does X
|
|
7
|
+
|
|
8
|
+
{{SCOPE_NOTE}}
|
|
9
|
+
|
|
10
|
+
The index produces the list. Your work is to open each row and confirm what it
|
|
11
|
+
does.
|
|
12
|
+
|
|
13
|
+
## 1. Two wordings
|
|
14
|
+
|
|
15
|
+
Ask in two wordings. One says what the code is for, the other says how it does
|
|
16
|
+
it. A purpose wording misses a helper whose description names only its
|
|
17
|
+
mechanism, and a mechanism wording misses one described only by its job.
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
search_by_intent({{SCOPE}}, query: "<what it is for>", also_phrased_as: ["<how it does it>"], compact: true, top_k: 20)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`compact: true` returns locator-only rows, which is the shape a census needs.
|
|
24
|
+
One answer holds both wordings, deduped, with `phrasings` naming them and
|
|
25
|
+
`matched_phrasings` on each row saying which ones reached it. Page it while
|
|
26
|
+
`has_more` is true:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
search_by_intent({{SCOPE}}, query: "<the same query>", also_phrased_as: ["<the same second wording>"], compact: true, top_k: 20, offset: <next_offset>)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Where the answer carries no `phrasings`, or the field comes back refused, the
|
|
33
|
+
server answering takes one wording per call. Run the second wording as its own
|
|
34
|
+
paged search.
|
|
35
|
+
|
|
36
|
+
Stop when `has_more` is false. Lower `similarity_threshold` where the tail of a
|
|
37
|
+
page is still on topic.
|
|
38
|
+
|
|
39
|
+
## 2. Union
|
|
40
|
+
|
|
41
|
+
One answer over both wordings is already deduped, and `matched_phrasings` says
|
|
42
|
+
which wordings reached each row. Two answers are joined here, on `file_path` and
|
|
43
|
+
`line_number`, and a row both wordings found is one row. Either way keep
|
|
44
|
+
`retrieved_by` and `similarity`, and keep which wording found each row. A row
|
|
45
|
+
only one wording reached is the row a single search would have lost.
|
|
46
|
+
|
|
47
|
+
## 3. Receipt
|
|
48
|
+
|
|
49
|
+
Report these numbers before the list, per query where the field is per query:
|
|
50
|
+
|
|
51
|
+
- rows read, which is the count you paged to;
|
|
52
|
+
- `total_candidates`, the exact size of the ranked list for that query;
|
|
53
|
+
- `hidden_by_threshold` where the answer carries it: how many rows the floor
|
|
54
|
+
removed, and `best` where it names the closest of them. A count above zero is
|
|
55
|
+
the case for one more search at a lower `similarity_threshold`;
|
|
56
|
+
- `total_without_intent`, which is index-wide: functions carrying no intent
|
|
57
|
+
text, which no search looked at;
|
|
58
|
+
- `total_intent_carried_forward` where the response states it, which are intents
|
|
59
|
+
describing the code as of an earlier scan.
|
|
60
|
+
|
|
61
|
+
Then say what the index does not hold for this question. It covers each repo's
|
|
62
|
+
main branch, so a change on a branch is outside it. It indexes functions that
|
|
63
|
+
carry an intent, so a match inside a config file, a template or generated output
|
|
64
|
+
is outside it as well.
|
|
65
|
+
|
|
66
|
+
## 4. Confirm
|
|
67
|
+
|
|
68
|
+
Read each row at `file_path`, from `line_number` to `end_line`. Keep the rows
|
|
69
|
+
that do the thing, and drop the rest with one line each saying what they turned
|
|
70
|
+
out to be. `role` on a row says what the scan joined it to, such as a route
|
|
71
|
+
handler or a client, and a route handler for a different operation is not a hit.
|
|
72
|
+
|
|
73
|
+
## 5. Report
|
|
74
|
+
|
|
75
|
+
| file:line | function | what it does | found by |
|
|
76
|
+
|---|---|---|---|
|
|
77
|
+
| src/billing/charge.ts:88 | chargeCard | reads the billing key | purpose, mechanism |
|
|
78
|
+
|
|
79
|
+
Close with the receipt from step 3, so the list is read against what was
|
|
80
|
+
searched.
|
|
81
|
+
|
|
82
|
+
## 6. Act
|
|
83
|
+
|
|
84
|
+
Change nothing unless you were asked to. Offer one issue per finding that needs
|
|
85
|
+
work, and file the ones accepted:
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
gh issue create --title "<concept>: <n> places, <m> need changing" --body "<the table, and the receipt>"
|
|
89
|
+
```
|