carrick 0.3.83 → 0.3.85

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 (68) hide show
  1. package/README.md +31 -11
  2. package/bin/carrick.mjs +89 -0
  3. package/dist/auth/credentials.d.ts +9 -0
  4. package/dist/auth/credentials.js +13 -2
  5. package/dist/auth/credentials.js.map +1 -1
  6. package/dist/auth/read.d.ts +4 -4
  7. package/dist/global-install.d.ts +183 -0
  8. package/dist/global-install.js +393 -0
  9. package/dist/global-install.js.map +1 -0
  10. package/dist/hook/post-edit.js +10 -0
  11. package/dist/hook/post-edit.js.map +1 -1
  12. package/dist/hook/session-start.js +34 -0
  13. package/dist/hook/session-start.js.map +1 -1
  14. package/dist/hook/stop.js +18 -4
  15. package/dist/hook/stop.js.map +1 -1
  16. package/dist/hook/user-prompt.js +16 -4
  17. package/dist/hook/user-prompt.js.map +1 -1
  18. package/dist/init/doctor.d.ts +61 -0
  19. package/dist/init/doctor.js +160 -1
  20. package/dist/init/doctor.js.map +1 -1
  21. package/dist/init/hosted.d.ts +30 -5
  22. package/dist/init/hosted.js +75 -9
  23. package/dist/init/hosted.js.map +1 -1
  24. package/dist/init/mcp.d.ts +45 -30
  25. package/dist/init/mcp.js +47 -88
  26. package/dist/init/mcp.js.map +1 -1
  27. package/dist/init/outdated.d.ts +47 -0
  28. package/dist/init/outdated.js +104 -0
  29. package/dist/init/outdated.js.map +1 -1
  30. package/dist/init/output.d.ts +43 -6
  31. package/dist/init/output.js +90 -18
  32. package/dist/init/output.js.map +1 -1
  33. package/dist/init/projects.d.ts +2 -2
  34. package/dist/init/run.d.ts +89 -25
  35. package/dist/init/run.js +292 -58
  36. package/dist/init/run.js.map +1 -1
  37. package/dist/render.d.ts +16 -0
  38. package/dist/render.js +57 -6
  39. package/dist/render.js.map +1 -1
  40. package/dist/scan.d.ts +26 -0
  41. package/dist/scan.js +92 -0
  42. package/dist/scan.js.map +1 -1
  43. package/dist/update-check.d.ts +1 -0
  44. package/dist/update-check.js +25 -0
  45. package/dist/update-check.js.map +1 -0
  46. package/dist/update.d.ts +128 -0
  47. package/dist/update.js +398 -0
  48. package/dist/update.js.map +1 -0
  49. package/package.json +8 -7
  50. package/sidecar/dist/src/capture/anchors.js +69 -8
  51. package/sidecar/dist/src/capture/api.d.ts +27 -2
  52. package/sidecar/dist/src/capture/check-classify.js +31 -2
  53. package/sidecar/dist/src/capture/check-fields.d.ts +29 -0
  54. package/sidecar/dist/src/capture/check-fields.js +58 -6
  55. package/sidecar/dist/src/capture/check.js +5 -0
  56. package/sidecar/dist/src/capture/deep-walk.js +4 -1
  57. package/sidecar/dist/src/capture/index.js +6 -1
  58. package/sidecar/dist/src/capture/node-builder.d.ts +28 -1
  59. package/sidecar/dist/src/capture/node-builder.js +89 -3
  60. package/sidecar/dist/src/capture/self-check.d.ts +6 -1
  61. package/sidecar/dist/src/capture/self-check.js +65 -11
  62. package/sidecar/dist/src/capture/unresolved.d.ts +7 -0
  63. package/sidecar/dist/src/capture/unresolved.js +1 -1
  64. package/sidecar/dist/src/type-inferrer.d.ts +118 -4
  65. package/sidecar/dist/src/type-inferrer.js +433 -15
  66. package/sidecar/dist/src/validators.d.ts +40 -40
  67. package/sidecar/dist/src/validators.js +6 -1
  68. package/templates/skills/carrick-census.md +3 -1
@@ -6,9 +6,9 @@
6
6
  */
7
7
  import ts from 'typescript';
8
8
  import * as path from 'node:path';
9
- import { printTypeForDestination, undeclaredNamesIn } from './node-builder.js';
9
+ import { printTypeForDestination, substituteUndeclaredNames, undeclaredNamesIn, } from './node-builder.js';
10
10
  import { typeIsOrContainsMachinery } from './machinery.js';
11
- import { unresolvedAtAnchor } from './unresolved.js';
11
+ import { unresolvedAtAnchor, unresolvedSpecifiersReachableFrom, } from './unresolved.js';
12
12
  /** Repo-root-relative source file -> extensionless specifier from entryDir. */
13
13
  export function entryRelativeSpecifier(entryDir, repoRoot, sourceFile) {
14
14
  const target = path
@@ -54,12 +54,25 @@ export function resolveAnchor(program, request, args) {
54
54
  // (a generated model that was never generated). The stub then self-checks
55
55
  // such a name as an error placeholder, which no walk flags. A name a
56
56
  // sibling symbol anchor imports is resolved by that import.
57
+ // carrick#1377: rewrite what nothing declares to `unknown` in place, so
58
+ // one member typed by a module the checkout does not have stops taking
59
+ // every member around it down with it.
60
+ const rewritten = siblingSpec || !args.placeholder
61
+ ? undefined
62
+ : substituteUndeclaredNamesInText(text, program, args.placeholder);
63
+ const aliasBody = rewritten?.text ?? text;
57
64
  const undeclaredNames = siblingSpec || !args.placeholder
58
65
  ? []
59
- : undeclaredNamesInText(text, program, args.placeholder);
66
+ : undeclaredNamesInText(aliasBody, program, args.placeholder);
67
+ const unresolved = rewritten?.paths.length
68
+ ? {
69
+ paths: rewritten.paths,
70
+ specifiers: unresolvedSpecifiersForLiteral(program, request.source_file, args.repoRoot),
71
+ }
72
+ : undefined;
60
73
  return {
61
74
  request,
62
- aliasText: siblingSpec ? `import('${siblingSpec}').${text}` : text,
75
+ aliasText: siblingSpec ? `import('${siblingSpec}').${text}` : aliasBody,
63
76
  // Literal anchors ARE the legacy-text tier (WP3 wiring of the design's
64
77
  // structural_fallback): hand-produced type text riding the surface.
65
78
  // The self-check still classifies decay; the fidelity metric counts
@@ -67,6 +80,7 @@ export function resolveAnchor(program, request, args) {
67
80
  // ratchetable. Demotions are distinguished by failureReason.
68
81
  serialization: 'structural_fallback',
69
82
  ...(undeclaredNames.length > 0 ? { undeclaredNames } : {}),
83
+ ...(unresolved ? { unresolved } : {}),
70
84
  };
71
85
  }
72
86
  const sourceAbs = path.join(args.repoRoot, request.source_file);
@@ -276,7 +290,12 @@ function finishInferAnchor(program, sourceFile, request, located, placeholder, r
276
290
  if (!printed.text) {
277
291
  return demote(printed.failure ?? 'node builder print failed');
278
292
  }
279
- const unresolved = unresolvedAtAnchor(program, sourceFile, type, located);
293
+ const atAnchor = unresolvedAtAnchor(program, sourceFile, type, located);
294
+ // carrick#1377: a member the print named undeclared and this rewrote to
295
+ // `unknown` is an unresolved position too, whether or not the source type
296
+ // carried the compiler's placeholder at it (a bare name the print reused as
297
+ // written does not). Both lists feed the same labelling.
298
+ const unresolved = mergeUnresolved(atAnchor, printed.substitutedPaths, () => unresolvedSpecifiersReachableFrom(program, sourceFile));
280
299
  return {
281
300
  request,
282
301
  aliasText: printed.text,
@@ -286,13 +305,55 @@ function finishInferAnchor(program, sourceFile, request, located, placeholder, r
286
305
  ...(unresolved ? { unresolved } : {}),
287
306
  };
288
307
  }
308
+ /**
309
+ * Fold substituted member positions into what the source program could not
310
+ * resolve. `specifiers` is a thunk: the reachable-import walk is only worth
311
+ * running for an anchor that actually substituted something.
312
+ */
313
+ function mergeUnresolved(atAnchor, substitutedPaths, specifiers) {
314
+ if (!substitutedPaths || substitutedPaths.length === 0)
315
+ return atAnchor;
316
+ const paths = new Set([...(atAnchor?.paths ?? []), ...substitutedPaths]);
317
+ return {
318
+ paths: [...paths],
319
+ specifiers: atAnchor?.specifiers ?? specifiers(),
320
+ };
321
+ }
322
+ /**
323
+ * The unresolved specifiers a LITERAL anchor's source file reaches, or none
324
+ * when the anchor names no file (its text names a type with nothing behind it
325
+ * and the detail says only that).
326
+ */
327
+ function unresolvedSpecifiersForLiteral(program, sourceFileRel, repoRoot) {
328
+ if (!sourceFileRel)
329
+ return [];
330
+ const sourceFile = program.getSourceFile(path.join(repoRoot, sourceFileRel));
331
+ return sourceFile ? unresolvedSpecifiersReachableFrom(program, sourceFile) : [];
332
+ }
289
333
  /** `undeclaredNamesIn` over type text rather than a built node. */
290
334
  function undeclaredNamesInText(text, program, destination) {
335
+ const parsed = parseLiteralAnchor(text);
336
+ return parsed ? undeclaredNamesIn(parsed, program, destination) : [];
337
+ }
338
+ /** `substituteUndeclaredNames` over type text rather than a built node. */
339
+ function substituteUndeclaredNamesInText(text, program, destination) {
340
+ const parsed = parseLiteralAnchor(text);
341
+ if (!parsed)
342
+ return undefined;
343
+ const rewritten = substituteUndeclaredNames(parsed, program, destination);
344
+ if (rewritten.substitutions.length === 0)
345
+ return undefined;
346
+ const printer = ts.createPrinter({ removeComments: true });
347
+ return {
348
+ text: printer.printNode(ts.EmitHint.Unspecified, rewritten.node, parsed.getSourceFile()),
349
+ paths: rewritten.substitutions.map((entry) => entry.path),
350
+ };
351
+ }
352
+ /** The type node of `type __LiteralAnchor = <text>;`, or undefined. */
353
+ function parseLiteralAnchor(text) {
291
354
  const parsed = ts.createSourceFile('literal-anchor.ts', `type __LiteralAnchor = ${text};`, ts.ScriptTarget.Latest, true);
292
355
  const statement = parsed.statements[0];
293
- if (!statement || !ts.isTypeAliasDeclaration(statement))
294
- return [];
295
- return undeclaredNamesIn(statement.type, program, destination);
356
+ return statement && ts.isTypeAliasDeclaration(statement) ? statement.type : undefined;
296
357
  }
297
358
  /**
298
359
  * True when the anchor carries a LINE and nothing else — no payload span, no
@@ -13,7 +13,11 @@
13
13
  * (Named anchor_origin because `provenance` is taken by the op-level
14
14
  * producer-provenance fields in src/eval_output.rs.)
15
15
  */
16
- export type AnchorOrigin = 'llm-symbol' | 'deterministic-infer' | 'anchor-backfill';
16
+ export type AnchorOrigin = 'llm-symbol' | 'deterministic-infer' | 'anchor-backfill'
17
+ /** No type request reached capture for a manifest alias, so the driver sent
18
+ * an `unknown` placeholder: the surface carries every alias the check will
19
+ * probe, and the check's IsUnknown gate answers for it (cloud#1184). */
20
+ | 'manifest-placeholder';
17
21
  /**
18
22
  * Serialization tier of a captured alias (design doc, Capture step 5):
19
23
  * - emitted: compiler declaration emit of an addressable symbol (best)
@@ -160,10 +164,13 @@ export type SelfCheckOutcome = 'ok' | 'allowlisted_external' | 'decayed_internal
160
164
  * - `no_request_body`: the located request read is a validated part the
161
165
  * route's validator binds that is not a body (a path parameter, a query), so
162
166
  * the route states no request body contract there (carrick#1166).
167
+ * - `projected_value_only`: every read of a call's result takes a member out
168
+ * of it and none reads the value itself, so the site states a part of a
169
+ * payload rather than the payload a caller receives (carrick#1375).
163
170
  * - `not_recorded`: the position carries a top type and this layer has no
164
171
  * cause for it.
165
172
  */
166
- export type TypeProvenanceReason = 'declared' | 'unresolved_import' | 'budget_exhausted' | 'no_payload_evidence' | 'machinery_envelope' | 'coerced_input' | 'no_success_payload' | 'no_request_body' | 'not_recorded';
173
+ export type TypeProvenanceReason = 'declared' | 'unresolved_import' | 'budget_exhausted' | 'no_payload_evidence' | 'machinery_envelope' | 'coerced_input' | 'no_success_payload' | 'no_request_body' | 'projected_value_only' | 'not_recorded';
167
174
  /**
168
175
  * One `any`/`unknown` finding inside a captured or inferred type, with its
169
176
  * position and its cause. Sorted by `path` wherever a list is emitted, so the
@@ -367,6 +374,24 @@ export interface CheckVerdict {
367
374
  resolved: boolean;
368
375
  /** Why `resolved` is false. Absent exactly when `resolved` is true. */
369
376
  unresolved_reason?: string;
377
+ /**
378
+ * Statements about THIS comparison that are neither the verdict nor an
379
+ * unresolution (carrick#1341).
380
+ *
381
+ * Two things the check can find are true of a pair it called COMPATIBLE, so
382
+ * neither can ride on `diagnostic`: an optionality gap (the sending side
383
+ * always provides a field the receiving side declares optional — legal,
384
+ * assigns, and still a drift between two sources), and the note that the
385
+ * comparison was made against the JSON wire form rather than the declared
386
+ * one (carrick#1340), which is why a producer `Date` read as a `string` is
387
+ * not reported.
388
+ *
389
+ * Written on EVERY bucket, empty when there is nothing to add. A note never
390
+ * changes `bucket` or `resolved` and is never a substitute for
391
+ * `diagnostic`: a reader that renders notes as a mismatch is reading an
392
+ * observation as a verdict.
393
+ */
394
+ notes: string[];
370
395
  }
371
396
  /**
372
397
  * A service degraded SERVICE-WIDE: install failure, or a stub-tree diagnostic
@@ -23,7 +23,7 @@
23
23
  */
24
24
  import { decisiveAssignmentLine } from './check-probe.js';
25
25
  import { scrubDiagnostic, scrubPaths } from './check-scrub.js';
26
- import { describeFieldReport } from './check-fields.js';
26
+ import { describeFieldReport, fieldReportNotes, } from './check-fields.js';
27
27
  const PRIMARY_RE = /^(?<file>(?:[a-zA-Z]:)?[^(]*?)\((?<line>\d+),(?<col>\d+)\): error TS(?<code>\d+): (?<msg>.*)$/;
28
28
  /** Parse `tsc --pretty false` output into structured diagnostics. */
29
29
  export function parseTscOutput(stdout) {
@@ -69,7 +69,17 @@ function endpointAliasFor(side, plan) {
69
69
  export function classifyPair(input) {
70
70
  const { plan, probeDiags, poisonReason, scrubCtx } = input;
71
71
  const codes = [...new Set(probeDiags.map((d) => d.code))].sort((a, b) => a - b);
72
- const base = { pair_id: plan.pairId, pair_key: plan.spec.pair_key, codes };
72
+ // `notes` sits on `base` so EVERY bucket carries it, including the
73
+ // compatible one (carrick#1341). The two statements it holds are true of a
74
+ // pair that agreed — an optionality gap assigns, and the wire allowance is
75
+ // exactly what made the pair agree — so a channel only reachable from the
76
+ // mismatch branch would be the same missing channel the ticket is about.
77
+ const base = {
78
+ pair_id: plan.pairId,
79
+ pair_key: plan.spec.pair_key,
80
+ codes,
81
+ notes: pairNotes(input, scrubCtx),
82
+ };
73
83
  // Every branch below this point except the last two returns a verdict about
74
84
  // a type nobody could read; each states that in one place rather than
75
85
  // repeating the reasoning.
@@ -231,6 +241,25 @@ export function classifyPair(input) {
231
241
  * name. Scrubbed on the same terms as the tsc text: a printed member type can
232
242
  * carry a stub-absolute `import("...")` path.
233
243
  */
244
+ /**
245
+ * The observations this pair states that are not its verdict (carrick#1341):
246
+ * the note that the comparison was made against the serialised form, and each
247
+ * optionality gap the walk found. Empty when the walk did not run or found
248
+ * nothing to add — never a claim that the two sides agree.
249
+ *
250
+ * Scrubbed on the same terms as the diagnostic: a printed member type can
251
+ * carry a stub-absolute `import("...")` path.
252
+ *
253
+ * A note is an OBSERVATION and never moves a bucket. Nothing in this function
254
+ * reaches `bucket`, `resolved` or `unresolved_reason`, and the pair's verdict
255
+ * is decided above before this is read.
256
+ */
257
+ function pairNotes(input, scrubCtx) {
258
+ const report = input.fieldReport;
259
+ if (!report)
260
+ return [];
261
+ return fieldReportNotes(report, input.plan.direction.sent, input.plan.direction.expected).map((note) => scrubPaths(note, scrubCtx));
262
+ }
234
263
  function namedFields(input, scrubCtx) {
235
264
  const report = input.fieldReport;
236
265
  if (!report)
@@ -75,6 +75,35 @@ export declare const MAX_NAMED_FIELDS = 8;
75
75
  * types agree.
76
76
  */
77
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[];
78
107
  /**
79
108
  * The sentence appended to a mismatch diagnostic. Names the two sides as
80
109
  * producer and consumer (never the probe's internal sent/expected), so the
@@ -75,11 +75,22 @@ export function pairFieldReports(opened, plans) {
75
75
  if (!declared || !expected)
76
76
  continue;
77
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));
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;
83
94
  results.set(plan.pairId, report);
84
95
  }
85
96
  return results;
@@ -221,6 +232,47 @@ function printType(type, ctx) {
221
232
  const flat = text.replace(/\s+/g, ' ').trim();
222
233
  return flat.length > MAX_TYPE_TEXT ? `${flat.slice(0, MAX_TYPE_TEXT - 1)}…` : flat;
223
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
+ }
224
276
  /**
225
277
  * The sentence appended to a mismatch diagnostic. Names the two sides as
226
278
  * producer and consumer (never the probe's internal sent/expected), so the
@@ -229,7 +281,7 @@ function printType(type, ctx) {
229
281
  export function describeFieldReport(report, sentSide, expectedSide) {
230
282
  const parts = [];
231
283
  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.`);
284
+ parts.push(wireFormNote(sentSide));
233
285
  }
234
286
  if (report.differences.length > 0) {
235
287
  const named = report.differences
@@ -83,6 +83,9 @@ function unverifiableAll(plans, gate, diagnostic) {
83
83
  codes: [],
84
84
  resolved: false,
85
85
  unresolved_reason: diagnostic,
86
+ // Nothing was compared here, so there is nothing to observe about the
87
+ // comparison (carrick#1341). Empty is the honest answer, not a default.
88
+ notes: [],
86
89
  }));
87
90
  }
88
91
  function sortVerdicts(verdicts) {
@@ -142,6 +145,7 @@ export async function runCheck(opts, onProgress) {
142
145
  codes: [],
143
146
  resolved: false,
144
147
  unresolved_reason: 'one side of this pair has no captured type surface',
148
+ notes: [],
145
149
  });
146
150
  }
147
151
  }
@@ -186,6 +190,7 @@ export async function runCheck(opts, onProgress) {
186
190
  unresolved_reason: hit.kind === 'budget_exhausted'
187
191
  ? `the ${hit.side} type is too deep or wide to verify at '${hit.path}'`
188
192
  : `the ${hit.side} type carries '${hit.kind}' at '${hit.path}'`,
193
+ notes: [],
189
194
  });
190
195
  }
191
196
  writeProbes(ws, probing);
@@ -266,7 +266,10 @@ export function provenanceOf(finding, unresolved) {
266
266
  detail: 'the type is too deep or wide to verify within the capture budget here, so it is reported unverified rather than assumed clean',
267
267
  };
268
268
  }
269
- if (finding.kind === 'any' && unresolved?.paths.includes(finding.path)) {
269
+ // `unknown` reads here as well as `any` (carrick#1377): a reference nothing
270
+ // declares is rewritten to `unknown` at its own position, and a reader told
271
+ // the author declared it that way stops looking where the fix is.
272
+ if (unresolved?.paths.includes(finding.path)) {
270
273
  return {
271
274
  path: finding.path,
272
275
  kind: finding.kind,
@@ -95,7 +95,12 @@ function emptyFidelity() {
95
95
  total_aliases: 0,
96
96
  by_serialization: { emitted: 0, node_builder: 0, structural_fallback: 0 },
97
97
  by_self_check: { ok: 0, allowlisted_external: 0, decayed_internal: 0 },
98
- by_anchor_origin: { 'llm-symbol': 0, 'deterministic-infer': 0, 'anchor-backfill': 0 },
98
+ by_anchor_origin: {
99
+ 'llm-symbol': 0,
100
+ 'deterministic-infer': 0,
101
+ 'anchor-backfill': 0,
102
+ 'manifest-placeholder': 0,
103
+ },
99
104
  usable_rate: 0,
100
105
  };
101
106
  }
@@ -30,9 +30,23 @@ export interface NodeBuilderPrintResult {
30
30
  failure?: string;
31
31
  /**
32
32
  * Names the print refers to that do not resolve at the destination in the
33
- * producer's program (carrick#1165). Present only when there are some.
33
+ * producer's program (carrick#1165). Present only when there are some —
34
+ * which, since carrick#1377 rewrites what it can reach, means a reference
35
+ * the substitution could not replace.
34
36
  */
35
37
  undeclaredNames?: string[];
38
+ /**
39
+ * Member positions the print named something undeclared at, and where that
40
+ * reference now reads `unknown` (carrick#1377). In the walk's own path
41
+ * notation, so a finding at one of them can be labelled as what it is: a
42
+ * module that did not resolve, not a declared top type.
43
+ */
44
+ substitutedPaths?: string[];
45
+ }
46
+ /** One reference replaced by `unknown`, with the member position it sat at. */
47
+ export interface UndeclaredSubstitution {
48
+ name: string;
49
+ path: string;
36
50
  }
37
51
  /**
38
52
  * Print `type` as a type node anchored at `destination` (a declaration inside
@@ -40,6 +54,19 @@ export interface NodeBuilderPrintResult {
40
54
  * any referenced symbol is not plainly accessible from the destination.
41
55
  */
42
56
  export declare function printTypeForDestination(program: ts.Program, type: ts.Type, destination: ts.Node): NodeBuilderPrintResult;
57
+ /**
58
+ * Rewrite every reference in `node` that nothing in the producer's program
59
+ * declares to the `unknown` keyword, and report the member position each sat
60
+ * at (carrick#1377).
61
+ *
62
+ * The positions use the same notation as the capture's deep walk — `sub`,
63
+ * `items<0>.meta`, `[index]`, `()` for a callable return — so a path found
64
+ * here names the same member a self-check finding at that path names.
65
+ */
66
+ export declare function substituteUndeclaredNames(node: ts.TypeNode, program: ts.Program, destination: ts.Node): {
67
+ node: ts.TypeNode;
68
+ substitutions: UndeclaredSubstitution[];
69
+ };
43
70
  /**
44
71
  * Bare names in a printed type node that nothing in the producer's program
45
72
  * declares (carrick#1165).
@@ -117,10 +117,96 @@ export function printTypeForDestination(program, type, destination) {
117
117
  failure: `symbols not accessible from the surface entry: ${[...new Set(inaccessible)].join(', ')}`,
118
118
  };
119
119
  }
120
+ // carrick#1377: a reference nothing declares makes the WHOLE answer
121
+ // unpublishable, so one member typed by a package the checkout does not
122
+ // have used to discard every member around it. Replace what it names with
123
+ // `unknown` in place and print that; the rest of the shape survives, and
124
+ // the positions are reported so the finding at each can be labelled as a
125
+ // module that did not resolve rather than a top type the author declared.
126
+ const substituted = substituteUndeclaredNames(node, program, destination);
120
127
  const printer = ts.createPrinter({ removeComments: true });
121
- const text = printer.printNode(ts.EmitHint.Unspecified, node, destination.getSourceFile());
122
- const undeclaredNames = undeclaredNamesIn(node, program, destination);
123
- return { text, inaccessible, ...(undeclaredNames.length > 0 ? { undeclaredNames } : {}) };
128
+ const text = printer.printNode(ts.EmitHint.Unspecified, substituted.node, destination.getSourceFile());
129
+ // Asked of the REWRITTEN node: the field says what the printed answer
130
+ // names, so anything the substitution could not reach still fills it and
131
+ // still refuses publication.
132
+ const undeclaredNames = undeclaredNamesIn(substituted.node, program, destination);
133
+ const substitutedPaths = substituted.substitutions.map((entry) => entry.path);
134
+ return {
135
+ text,
136
+ inaccessible,
137
+ ...(undeclaredNames.length > 0 ? { undeclaredNames } : {}),
138
+ ...(substitutedPaths.length > 0 ? { substitutedPaths } : {}),
139
+ };
140
+ }
141
+ /**
142
+ * Rewrite every reference in `node` that nothing in the producer's program
143
+ * declares to the `unknown` keyword, and report the member position each sat
144
+ * at (carrick#1377).
145
+ *
146
+ * The positions use the same notation as the capture's deep walk — `sub`,
147
+ * `items<0>.meta`, `[index]`, `()` for a callable return — so a path found
148
+ * here names the same member a self-check finding at that path names.
149
+ */
150
+ export function substituteUndeclaredNames(node, program, destination) {
151
+ const undeclared = new Set(undeclaredNamesIn(node, program, destination));
152
+ if (undeclared.size === 0) {
153
+ return { node, substitutions: [] };
154
+ }
155
+ const substitutions = [];
156
+ const leftmost = (name) => ts.isIdentifier(name) ? name : leftmost(name.left);
157
+ // The path is carried down the visit rather than reconstructed, because a
158
+ // rewritten node has no parent to walk back up from.
159
+ const rewrite = (current, path) => {
160
+ if ((ts.isTypeReferenceNode(current) && undeclared.has(leftmost(current.typeName).text)) ||
161
+ (ts.isTypeQueryNode(current) && undeclared.has(leftmost(current.exprName).text))) {
162
+ substitutions.push({
163
+ name: ts.isTypeReferenceNode(current)
164
+ ? leftmost(current.typeName).text
165
+ : leftmost(current.exprName).text,
166
+ path: path === '' ? '<root>' : path,
167
+ });
168
+ return ts.factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword);
169
+ }
170
+ return ts.visitEachChild(current, (child) => rewrite(child, childPath(current, child, path)),
171
+ /* context */ undefined);
172
+ };
173
+ const rewritten = rewrite(node, '');
174
+ return { node: rewritten, substitutions };
175
+ }
176
+ /** The deep walk's path for `child` inside `parent`, extending `path`. */
177
+ function childPath(parent, child, path) {
178
+ if (ts.isPropertySignature(parent) && parent.type === child) {
179
+ const name = ts.isIdentifier(parent.name) || ts.isStringLiteral(parent.name)
180
+ ? parent.name.text
181
+ : parent.name.getText?.() ?? '';
182
+ return path === '' ? name : `${path}.${name}`;
183
+ }
184
+ if (ts.isArrayTypeNode(parent) && parent.elementType === child) {
185
+ return `${path}<0>`;
186
+ }
187
+ if (ts.isIndexSignatureDeclaration(parent) && parent.type === child) {
188
+ return `${path}[index]`;
189
+ }
190
+ if ((ts.isFunctionTypeNode(parent) ||
191
+ ts.isMethodSignature(parent) ||
192
+ ts.isCallSignatureDeclaration(parent)) &&
193
+ parent.type === child) {
194
+ return `${path}()`;
195
+ }
196
+ if (ts.isTypeReferenceNode(parent) && parent.typeArguments) {
197
+ const index = parent.typeArguments.indexOf(child);
198
+ if (index >= 0)
199
+ return `${path}<${index}>`;
200
+ }
201
+ if (ts.isTupleTypeNode(parent)) {
202
+ const index = parent.elements.indexOf(child);
203
+ if (index >= 0)
204
+ return `${path}<${index}>`;
205
+ }
206
+ // A union or intersection member sits at its parent's position, as the deep
207
+ // walk records it; everything else (a type literal's members, a parenthesis)
208
+ // keeps the path it was reached with.
209
+ return path;
124
210
  }
125
211
  /**
126
212
  * Bare names in a printed type node that nothing in the producer's program
@@ -22,7 +22,12 @@
22
22
  * Attribution is per-alias closure: failed specifiers are blamed on an alias
23
23
  * only if they occur in a file reachable from that alias's surface statement
24
24
  * (import-type seeds, then BFS over relative imports). The spike's
25
- * file-granularity shortcut is gone.
25
+ * file-granularity shortcut is gone -- including on the SURFACE file itself,
26
+ * which holds every alias, so a file-granular bucket there blamed the whole
27
+ * service for one alias's dangling specifier (cloud#1184). A surface
28
+ * diagnostic is attributed by `export type` statement span, exactly as
29
+ * check-poison.ts contains poison; one that no statement covers keeps the
30
+ * service-wide bucket.
26
31
  */
27
32
  import ts from 'typescript';
28
33
  import type { CaptureAliasRecord } from './api.js';