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.
Files changed (79) hide show
  1. package/README.md +46 -19
  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 +36 -0
  27. package/dist/init/doctor.js +97 -2
  28. package/dist/init/doctor.js.map +1 -1
  29. package/dist/init/files.d.ts +16 -0
  30. package/dist/init/files.js +35 -0
  31. package/dist/init/files.js.map +1 -0
  32. package/dist/init/outdated.d.ts +54 -0
  33. package/dist/init/outdated.js +175 -0
  34. package/dist/init/outdated.js.map +1 -0
  35. package/dist/init/output.d.ts +35 -3
  36. package/dist/init/output.js +115 -23
  37. package/dist/init/output.js.map +1 -1
  38. package/dist/init/projects.d.ts +27 -13
  39. package/dist/init/projects.js +48 -50
  40. package/dist/init/projects.js.map +1 -1
  41. package/dist/init/remove.d.ts +0 -2
  42. package/dist/init/remove.js +91 -23
  43. package/dist/init/remove.js.map +1 -1
  44. package/dist/init/repos.d.ts +34 -0
  45. package/dist/init/repos.js +77 -0
  46. package/dist/init/repos.js.map +1 -1
  47. package/dist/init/run.d.ts +73 -6
  48. package/dist/init/run.js +382 -101
  49. package/dist/init/run.js.map +1 -1
  50. package/dist/init/settings.d.ts +20 -0
  51. package/dist/init/settings.js +42 -4
  52. package/dist/init/settings.js.map +1 -1
  53. package/dist/init/task-skills.d.ts +97 -0
  54. package/dist/init/task-skills.js +267 -0
  55. package/dist/init/task-skills.js.map +1 -0
  56. package/dist/init/workspace-file.d.ts +73 -0
  57. package/dist/init/workspace-file.js +173 -0
  58. package/dist/init/workspace-file.js.map +1 -0
  59. package/dist/scan.d.ts +13 -5
  60. package/dist/scan.js +21 -10
  61. package/dist/scan.js.map +1 -1
  62. package/dist/templates.d.ts +9 -0
  63. package/dist/templates.js +9 -1
  64. package/dist/templates.js.map +1 -1
  65. package/package.json +6 -6
  66. package/plugin/hooks/hooks.json +11 -0
  67. package/sidecar/dist/src/capture/check-classify.d.ts +10 -1
  68. package/sidecar/dist/src/capture/check-classify.js +66 -8
  69. package/sidecar/dist/src/capture/check-deep.d.ts +16 -4
  70. package/sidecar/dist/src/capture/check-deep.js +21 -17
  71. package/sidecar/dist/src/capture/check-fields.d.ts +83 -0
  72. package/sidecar/dist/src/capture/check-fields.js +259 -0
  73. package/sidecar/dist/src/capture/check-probe.d.ts +21 -1
  74. package/sidecar/dist/src/capture/check-probe.js +39 -0
  75. package/sidecar/dist/src/capture/check.js +9 -2
  76. package/templates/skills/carrick-census.md +89 -0
  77. package/templates/skills/carrick-drift.md +107 -0
  78. package/templates/skills/carrick-impact.md +108 -0
  79. 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 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,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. 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
  }
@@ -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 deepByPair = probeDeepFindings(ws.probesDir, probing);
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
+ ```