carrick 0.3.82 → 0.3.84

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 (88) hide show
  1. package/README.md +62 -30
  2. package/bin/carrick.mjs +45 -2
  3. package/dist/contract.d.ts +17 -0
  4. package/dist/contract.js.map +1 -1
  5. package/dist/hook/apply-patch.d.ts +13 -0
  6. package/dist/hook/apply-patch.js +100 -0
  7. package/dist/hook/apply-patch.js.map +1 -0
  8. package/dist/hook/post-edit.d.ts +30 -1
  9. package/dist/hook/post-edit.js +95 -24
  10. package/dist/hook/post-edit.js.map +1 -1
  11. package/dist/hook/reuse.d.ts +97 -0
  12. package/dist/hook/reuse.js +245 -0
  13. package/dist/hook/reuse.js.map +1 -0
  14. package/dist/hook/stop.d.ts +3 -0
  15. package/dist/hook/stop.js +76 -0
  16. package/dist/hook/stop.js.map +1 -0
  17. package/dist/hook/user-prompt.d.ts +9 -0
  18. package/dist/hook/user-prompt.js +79 -0
  19. package/dist/hook/user-prompt.js.map +1 -0
  20. package/dist/init/codex.d.ts +51 -0
  21. package/dist/init/codex.js +167 -0
  22. package/dist/init/codex.js.map +1 -0
  23. package/dist/init/connect.d.ts +13 -0
  24. package/dist/init/connect.js +21 -15
  25. package/dist/init/connect.js.map +1 -1
  26. package/dist/init/doctor.d.ts +57 -0
  27. package/dist/init/doctor.js +132 -3
  28. package/dist/init/doctor.js.map +1 -1
  29. package/dist/init/files.d.ts +15 -0
  30. package/dist/init/files.js +20 -1
  31. package/dist/init/files.js.map +1 -1
  32. package/dist/init/hosted.d.ts +30 -5
  33. package/dist/init/hosted.js +75 -9
  34. package/dist/init/hosted.js.map +1 -1
  35. package/dist/init/mcp.d.ts +45 -30
  36. package/dist/init/mcp.js +47 -88
  37. package/dist/init/mcp.js.map +1 -1
  38. package/dist/init/outdated.d.ts +54 -0
  39. package/dist/init/outdated.js +175 -0
  40. package/dist/init/outdated.js.map +1 -0
  41. package/dist/init/output.d.ts +72 -3
  42. package/dist/init/output.js +187 -23
  43. package/dist/init/output.js.map +1 -1
  44. package/dist/init/projects.d.ts +27 -13
  45. package/dist/init/projects.js +48 -50
  46. package/dist/init/projects.js.map +1 -1
  47. package/dist/init/remove.d.ts +0 -2
  48. package/dist/init/remove.js +61 -15
  49. package/dist/init/remove.js.map +1 -1
  50. package/dist/init/repos.d.ts +34 -0
  51. package/dist/init/repos.js +77 -0
  52. package/dist/init/repos.js.map +1 -1
  53. package/dist/init/run.d.ts +148 -26
  54. package/dist/init/run.js +577 -125
  55. package/dist/init/run.js.map +1 -1
  56. package/dist/init/settings.d.ts +20 -0
  57. package/dist/init/settings.js +42 -4
  58. package/dist/init/settings.js.map +1 -1
  59. package/dist/init/task-skills.d.ts +27 -0
  60. package/dist/init/task-skills.js +64 -1
  61. package/dist/init/task-skills.js.map +1 -1
  62. package/dist/init/workspace-file.d.ts +73 -0
  63. package/dist/init/workspace-file.js +173 -0
  64. package/dist/init/workspace-file.js.map +1 -0
  65. package/dist/render.d.ts +16 -0
  66. package/dist/render.js +57 -6
  67. package/dist/render.js.map +1 -1
  68. package/package.json +7 -6
  69. package/plugin/hooks/hooks.json +11 -0
  70. package/sidecar/dist/src/capture/api.d.ts +23 -1
  71. package/sidecar/dist/src/capture/check-classify.d.ts +10 -1
  72. package/sidecar/dist/src/capture/check-classify.js +96 -9
  73. package/sidecar/dist/src/capture/check-deep.d.ts +16 -4
  74. package/sidecar/dist/src/capture/check-deep.js +21 -17
  75. package/sidecar/dist/src/capture/check-fields.d.ts +112 -0
  76. package/sidecar/dist/src/capture/check-fields.js +311 -0
  77. package/sidecar/dist/src/capture/check-probe.d.ts +21 -1
  78. package/sidecar/dist/src/capture/check-probe.js +39 -0
  79. package/sidecar/dist/src/capture/check.js +14 -2
  80. package/sidecar/dist/src/capture/index.js +6 -1
  81. package/sidecar/dist/src/capture/self-check.d.ts +6 -1
  82. package/sidecar/dist/src/capture/self-check.js +65 -11
  83. package/sidecar/dist/src/validators.d.ts +40 -40
  84. package/sidecar/dist/src/validators.js +6 -1
  85. package/templates/skills/carrick-census.md +18 -9
  86. package/templates/skills/carrick-drift.md +7 -1
  87. package/templates/skills/carrick-impact.md +3 -1
  88. package/templates/skills/carrick-reuse.md +43 -26
@@ -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 cannot be built — absence of findings
29
- * must never be read as "clean", so the caller treats a missing entry as
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(probesDir: string, plans: ProbePlan[]): Map<string, PairDeepFindings>;
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 results;
24
+ return undefined;
33
25
  const configPath = path.join(probesDir, 'tsconfig.json');
34
26
  if (!fs.existsSync(configPath))
35
- return results;
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 results;
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 results;
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 results;
42
+ return undefined;
51
43
  }
52
- const checker = program.getTypeChecker();
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,112 @@
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 one wording for "this comparison was made against the serialised form",
80
+ * shared by the mismatch diagnostic and the `notes` channel so the two can
81
+ * never drift apart (carrick#1341).
82
+ */
83
+ export declare function wireFormNote(sentSide: Side): string;
84
+ /**
85
+ * The statements this report makes that are NOT a mismatch: what belongs on
86
+ * `CheckVerdict.notes` (carrick#1341).
87
+ *
88
+ * Two of the things the walk can find are true of a pair the judge called
89
+ * COMPATIBLE, and so have no mismatch diagnostic to ride on:
90
+ *
91
+ * - the wire note. Since carrick#1340 a producer `Date` read as a `string` is
92
+ * not a drift, because that is what arrives. A reader comparing the two
93
+ * declared shapes by hand sees `Date` against `string` and concludes the
94
+ * check missed it, so the verdict has to say the comparison was made
95
+ * against the serialised form.
96
+ * - an optionality gap (`optional_in_expected`): the sending side always
97
+ * provides a field the receiving side declares optional. That assigns, so
98
+ * no diagnostic can exist for it, and the two sources still disagree — the
99
+ * receiver carries a branch that never runs.
100
+ *
101
+ * Both are OBSERVATIONS. Nothing here is a verdict, nothing here may move one,
102
+ * and this is never a substitute for a mismatch reason: a caller that finds
103
+ * notes on an incompatible row has one statement made twice, not two
104
+ * statements. Returned in the report's own deterministic order.
105
+ */
106
+ export declare function fieldReportNotes(report: PairFieldReport, sentSide: Side, expectedSide: Side): string[];
107
+ /**
108
+ * The sentence appended to a mismatch diagnostic. Names the two sides as
109
+ * producer and consumer (never the probe's internal sent/expected), so the
110
+ * reader knows which repo to change.
111
+ */
112
+ export declare function describeFieldReport(report: PairFieldReport, sentSide: Side, expectedSide: Side): string;
@@ -0,0 +1,311 @@
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
+ // Whether serialising changes anything observable about the sent type.
79
+ const wireChanges = wire !== undefined &&
80
+ !(isAssignableTo(wire.type, declared.type) && isAssignableTo(declared.type, wire.type));
81
+ // Walk the DECLARED type unless serialising really changed it.
82
+ //
83
+ // The probe declares its wire comparand through a conditional alias that
84
+ // short-circuits back to the declared type whenever that already assigns,
85
+ // and a conditional the checker has not had to resolve carries no members
86
+ // to walk. Reading it unconditionally therefore emptied the report on
87
+ // exactly the pairs that AGREE — the ones whose only statement is an
88
+ // optionality gap or the wire note (carrick#1341). Where serialisation
89
+ // did change the type the wire form is a mapped type with real members,
90
+ // and it stays the thing compared, because that is what the judge judged.
91
+ const compared = wireChanges ? wire.type : declared.type;
92
+ const report = diffReport(compared, expected.type, checker, isAssignableTo, expected.node);
93
+ report.wireApplied = wireChanges;
94
+ results.set(plan.pairId, report);
95
+ }
96
+ return results;
97
+ }
98
+ /** The type of one of the probe's declared consts, read where it is declared. */
99
+ function declaredConstType(file, checker, name) {
100
+ for (const statement of file.statements) {
101
+ if (!ts.isVariableStatement(statement))
102
+ continue;
103
+ for (const declaration of statement.declarationList.declarations) {
104
+ if (!ts.isIdentifier(declaration.name) || declaration.name.text !== name)
105
+ continue;
106
+ const type = checker.getTypeAtLocation(declaration.name);
107
+ if (!type)
108
+ return undefined;
109
+ return { type, node: declaration.name };
110
+ }
111
+ }
112
+ return undefined;
113
+ }
114
+ function diffReport(sent, expected, checker, isAssignableTo, at) {
115
+ const found = [];
116
+ walk(sent, expected, '', 0, { checker, isAssignableTo, at, found });
117
+ found.sort((a, b) => a.path === b.path ? compareText(a.nature, b.nature) : compareText(a.path, b.path));
118
+ return {
119
+ differences: found.slice(0, MAX_NAMED_FIELDS),
120
+ truncated: Math.max(0, found.length - MAX_NAMED_FIELDS),
121
+ wireApplied: false,
122
+ };
123
+ }
124
+ function compareText(a, b) {
125
+ return a < b ? -1 : a > b ? 1 : 0;
126
+ }
127
+ /**
128
+ * A shape whose members can be compared one by one without diverging from the
129
+ * whole-type relation.
130
+ *
131
+ * The object flag is the first and widest of these: a union or an intersection
132
+ * does not carry it, and neither does a primitive, so a root the judge compared
133
+ * as a whole is never taken apart into members one of its constituents happens
134
+ * to share.
135
+ *
136
+ * An index signature is excluded because it makes the member list an incomplete
137
+ * account of the type: a field the sender provides that the receiver's index
138
+ * signature accepts is not a field the receiver "declares no such field" for,
139
+ * and saying so would be false. Arrays and tuples carry a numeric one, so the
140
+ * same clause keeps `length` and `push` out of a field list; an element
141
+ * difference is reported at the field that holds the array.
142
+ */
143
+ function isComparableObject(type, checker) {
144
+ if (type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.Never)) {
145
+ return false;
146
+ }
147
+ if ((type.flags & ts.TypeFlags.Object) === 0)
148
+ return false;
149
+ if (checker.getIndexInfosOfType(type).length > 0)
150
+ return false;
151
+ return true;
152
+ }
153
+ function walk(sent, expected, path, depth, ctx) {
154
+ const { checker } = ctx;
155
+ if (!isComparableObject(sent, checker) || !isComparableObject(expected, checker)) {
156
+ return;
157
+ }
158
+ const sentProps = new Map(sent.getProperties().map((p) => [p.getName(), p]));
159
+ const expectedProps = new Map(expected.getProperties().map((p) => [p.getName(), p]));
160
+ /** Members the receiver declares and the sender has no member for, optional
161
+ * ones included. Only the REQUIRED ones are a difference; the rest still
162
+ * count here, because a receiver waiting on a member it never gets is what
163
+ * makes a sender-only member worth naming beside it. */
164
+ let absent = 0;
165
+ for (const [name, expectedProp] of expectedProps) {
166
+ const at = join(path, name);
167
+ const sentProp = sentProps.get(name);
168
+ if (!sentProp) {
169
+ absent += 1;
170
+ // An optional member the sender omits is what optional MEANS. Naming it
171
+ // would state that the receiver requires it, which is false, and it is
172
+ // not what the judge rejected the pair for.
173
+ if (!isOptional(expectedProp)) {
174
+ ctx.found.push({ path: at, nature: 'missing_in_sent' });
175
+ }
176
+ continue;
177
+ }
178
+ const sentOptional = isOptional(sentProp);
179
+ const expectedOptional = isOptional(expectedProp);
180
+ if (sentOptional && !expectedOptional) {
181
+ ctx.found.push({ path: at, nature: 'optional_in_sent' });
182
+ }
183
+ else if (!sentOptional && expectedOptional) {
184
+ // No assignment error exists for this direction, which is exactly why
185
+ // the judge cannot report it and this walk must.
186
+ ctx.found.push({ path: at, nature: 'optional_in_expected' });
187
+ }
188
+ const sentType = memberType(sentProp, ctx);
189
+ const expectedType = memberType(expectedProp, ctx);
190
+ if (ctx.isAssignableTo(sentType, expectedType))
191
+ continue;
192
+ const sentInner = checker.getNonNullableType(sentType);
193
+ const expectedInner = checker.getNonNullableType(expectedType);
194
+ if (depth + 1 < MAX_FIELD_DEPTH &&
195
+ isComparableObject(sentInner, checker) &&
196
+ isComparableObject(expectedInner, checker)) {
197
+ walk(sentInner, expectedInner, at, depth + 1, ctx);
198
+ continue;
199
+ }
200
+ ctx.found.push({
201
+ path: at,
202
+ nature: 'type_differs',
203
+ sentText: printType(sentType, ctx),
204
+ expectedText: printType(expectedType, ctx),
205
+ });
206
+ }
207
+ // A field the sender provides that the receiver does not declare is normal
208
+ // (a response carrying more than a call site reads), so it is only worth
209
+ // naming beside a member the receiver is waiting on and does not get: that
210
+ // pairing is what a renamed or relocated field looks like from the outside.
211
+ // On the common subset case — a call site reading fewer fields than the
212
+ // producer returns — nothing is absent and nothing is named.
213
+ if (absent === 0)
214
+ return;
215
+ for (const [name] of sentProps) {
216
+ if (expectedProps.has(name))
217
+ continue;
218
+ ctx.found.push({ path: join(path, name), nature: 'extra_in_sent' });
219
+ }
220
+ }
221
+ function join(path, name) {
222
+ return path === '' ? name : `${path}.${name}`;
223
+ }
224
+ function isOptional(symbol) {
225
+ return (symbol.flags & ts.SymbolFlags.Optional) !== 0;
226
+ }
227
+ function memberType(symbol, ctx) {
228
+ return ctx.checker.getTypeOfSymbolAtLocation(symbol, ctx.at);
229
+ }
230
+ function printType(type, ctx) {
231
+ const text = ctx.checker.typeToString(type, undefined, ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.InTypeAlias);
232
+ const flat = text.replace(/\s+/g, ' ').trim();
233
+ return flat.length > MAX_TYPE_TEXT ? `${flat.slice(0, MAX_TYPE_TEXT - 1)}…` : flat;
234
+ }
235
+ /**
236
+ * The one wording for "this comparison was made against the serialised form",
237
+ * shared by the mismatch diagnostic and the `notes` channel so the two can
238
+ * never drift apart (carrick#1341).
239
+ */
240
+ export function wireFormNote(sentSide) {
241
+ return `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.`;
242
+ }
243
+ /**
244
+ * The statements this report makes that are NOT a mismatch: what belongs on
245
+ * `CheckVerdict.notes` (carrick#1341).
246
+ *
247
+ * Two of the things the walk can find are true of a pair the judge called
248
+ * COMPATIBLE, and so have no mismatch diagnostic to ride on:
249
+ *
250
+ * - the wire note. Since carrick#1340 a producer `Date` read as a `string` is
251
+ * not a drift, because that is what arrives. A reader comparing the two
252
+ * declared shapes by hand sees `Date` against `string` and concludes the
253
+ * check missed it, so the verdict has to say the comparison was made
254
+ * against the serialised form.
255
+ * - an optionality gap (`optional_in_expected`): the sending side always
256
+ * provides a field the receiving side declares optional. That assigns, so
257
+ * no diagnostic can exist for it, and the two sources still disagree — the
258
+ * receiver carries a branch that never runs.
259
+ *
260
+ * Both are OBSERVATIONS. Nothing here is a verdict, nothing here may move one,
261
+ * and this is never a substitute for a mismatch reason: a caller that finds
262
+ * notes on an incompatible row has one statement made twice, not two
263
+ * statements. Returned in the report's own deterministic order.
264
+ */
265
+ export function fieldReportNotes(report, sentSide, expectedSide) {
266
+ const notes = [];
267
+ if (report.wireApplied)
268
+ notes.push(wireFormNote(sentSide));
269
+ for (const difference of report.differences) {
270
+ if (difference.nature !== 'optional_in_expected')
271
+ continue;
272
+ notes.push(`${describeDifference(difference, sentSide, expectedSide)}.`);
273
+ }
274
+ return notes;
275
+ }
276
+ /**
277
+ * The sentence appended to a mismatch diagnostic. Names the two sides as
278
+ * producer and consumer (never the probe's internal sent/expected), so the
279
+ * reader knows which repo to change.
280
+ */
281
+ export function describeFieldReport(report, sentSide, expectedSide) {
282
+ const parts = [];
283
+ if (report.wireApplied) {
284
+ parts.push(wireFormNote(sentSide));
285
+ }
286
+ if (report.differences.length > 0) {
287
+ const named = report.differences
288
+ .map((d) => describeDifference(d, sentSide, expectedSide))
289
+ .join('; ');
290
+ const more = report.truncated > 0
291
+ ? `; and ${report.truncated} further field${report.truncated === 1 ? '' : 's'} differ${report.truncated === 1 ? 's' : ''} (${MAX_NAMED_FIELDS} named here)`
292
+ : '';
293
+ parts.push(`Fields that differ: ${named}${more}.`);
294
+ }
295
+ return parts.length === 0 ? '' : ` ${parts.join(' ')}`;
296
+ }
297
+ function describeDifference(difference, sentSide, expectedSide) {
298
+ const at = `'${difference.path}'`;
299
+ switch (difference.nature) {
300
+ case 'missing_in_sent':
301
+ return `${at} is required by the ${expectedSide} and the ${sentSide} does not send it`;
302
+ case 'extra_in_sent':
303
+ return `${at} is sent by the ${sentSide} and the ${expectedSide} declares no such field`;
304
+ case 'optional_in_sent':
305
+ return `${at} is optional on the ${sentSide} and required by the ${expectedSide}`;
306
+ case 'optional_in_expected':
307
+ return `${at} is always sent by the ${sentSide} and optional on the ${expectedSide}`;
308
+ case 'type_differs':
309
+ return `${at} is ${difference.sentText} on the ${sentSide} and ${difference.expectedText} on the ${expectedSide}`;
310
+ }
311
+ }
@@ -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. Errors here => incompatible. */
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
  }