@suss/ir-core 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -31,17 +31,39 @@ The boundary key uses only the subject, so both forms end up in the same bucket
31
31
 
32
32
  Schemas are the single source of truth (`@suss/ir-core/schemas`); the types are derived from them. The recursive `TypeShape` is a hand-written named export so consuming packages reference it by name across the package boundary rather than inlining the recursion.
33
33
 
34
- ## Names with a hole in them
34
+ ## Boundary names
35
35
 
36
- A deployed resource is often called something built at deploy time. A template writes `!Sub "${StageName}-orders-v1"` and the code writes `` `${stage}-orders-v1` ``, so neither side states a string, both agree about the fixed text, and each spells the parameter its own way. A name is written here as fixed text with `{}` holes, and `namesAgree` says whether two of them are the same name.
36
+ A container or an access path in a summary is a string that means one of three things, and the braces alone say which:
37
37
 
38
- A hole stops at the separator between it and what comes next. No separator works for every project, so the rule does not name one, it takes the separator from the pattern: when the fixed text after a hole starts with a character that is not a letter or a digit, that character divides the deploy-time value from the rest of the name, and the value may not contain it. So `{env}-publications-v1` covers `prod-publications-v1`, and it does not cover `prod-creator-publications-v1`, which the hole could only reach by swallowing a `-`.
38
+ - A **literal** (`orders-v1`) is the name itself.
39
+ - A **pattern** (`{stage}-orders-v1`) is fixed text with `{}` holes for the parts a deployment fills in. A template writes `!Sub "${StageName}-orders-v1"` and the code writes `` `${stage}-orders-v1` ``: neither states a string, both agree about the fixed text, and each spells the parameter its own way.
40
+ - A **reference** (`{location.bucket}`, `{ORDER_TABLE}`) is one hole and no fixed text, saying where to go and ask rather than what the name is.
39
41
 
40
- A greedy hole was the earlier rule, and a module declaring both of those tables had one storage access pair with each of them. The table the code never touches keys on something else, so the run reported a selector mismatch on a boundary that code never reaches.
42
+ `boundaryName.ts` owns the syntax in both directions. `parseBoundaryName` turns a string into the discriminated `BoundaryName` value, `boundaryNameString` turns one back into exactly the string it came from, and everything else, `namesAgree`, `namesNothing`, `fixedTextLength`, `referenceName`, `referenceFromName`, is a view over that pair. Nothing outside the module reads or writes the braces itself: a second parser can disagree about which of the three a string is, and a second printer can spell a value the parser cannot read back. Both have happened. The two halves of caller grounding once spelled a reference differently and never met (#456), which is why writing one and reading one are the same module rather than a format each side implements for itself. `check:name-syntax` in CI keeps it that way.
41
43
 
42
- A hole at the end of a name, and a hole whose next character is a letter or a digit, has no separator to stop at, and it still covers anything. That costs a pair when the value has the separator inside it: `{region}-orders` does not cover `us-east-1-orders`, though `orders-{region}` still covers `orders-us-east-1`. Missing a pair is the better failure of the two, since a wrong match reports findings about a store the code never touches.
44
+ The wire format is the string. Summaries on disk carry `orders-v1`, `{stage}-orders-v1`, `{location.table}` unchanged, so nothing about this layout is a schema change, and an old summary reads the same as a new one.
43
45
 
44
- One name can still be covered by two patterns. A hole at the end covers anything, so `orders-{suffix}` and `orders-blue-{suffix}` both cover `orders-blue-v1`. Choosing between them belongs to whichever pass picks a provider for an access, and `fixedTextLength` is what such a pass ranks by: the pattern that states more fixed text is the more specific one, and two patterns that state the same amount settle nothing between them.
46
+ ### How two names pair
47
+
48
+ `namesAgree` says whether two names are the same name. Two patterns agree when their fixed parts line up, since a hole on one side meets a hole on the other. A pattern and a literal agree when the fixed text is where the pattern says it is, which is what happens when one side hardcoded what the other parameterized. A reference agrees with nothing until something settles it, since one bare hole would otherwise agree with every name there is.
49
+
50
+ A hole covers anything, because a name has no separator every project agrees on: a region is written `us-east-1`, and a hole that stopped at the first hyphen would miss it. The cost is that one name can be covered by two patterns, `orders-{suffix}` and `orders-blue-{suffix}` both cover `orders-blue-v1`, so choosing between them belongs to whichever pass picks a provider for an access. `fixedTextLength` is what such a pass ranks by: the pattern that states more fixed text is the more specific one, and two patterns that state the same amount settle nothing between them.
51
+
52
+ ### A name that says where to go and ask
53
+
54
+ Some code cannot say what it reaches. A storage layer takes the bucket as an argument, and a service reads its table name out of the deployment. Neither states a name, and a name is what the rest of suss pairs on, so both write a reference instead.
55
+
56
+ ```ts
57
+ referenceName({ root: "location", fields: ["bucket"] }); // "{location.bucket}"
58
+ referenceName({ root: "ORDER_TABLE", fields: [] }); // "{ORDER_TABLE}"
59
+ referenceFromName("{location.bucket}"); // { root: "location", fields: ["bucket"] }
60
+ ```
61
+
62
+ The root is the value the code starts from, and the fields are what it reads inside that value. A language adapter writes one while reading source, and the checker settles it much later by joining over a whole run: `{location.bucket}` against what each caller passed for `location`, `{ORDER_TABLE}` against what the deployment sets. Whether the root is a parameter of the unit or a variable the deployment sets is not in the string, on purpose: it depends on the unit's inputs and their roles, which the grounding pass already has in hand, and a bare `{X}` is deliberately tried both ways there. A root kind written into the name would either be ignored or change which of those runs.
63
+
64
+ ### Two look-alike conventions that stay apart
65
+
66
+ A REST route path also spells its parameters with braces (`/users/{id}`), but a route hole stops at the `/` between segments, so route paths keep their own comparison beside the REST semantics. A message-bus channel never uses braces at all; its `bus#subject` form has its own module, described above.
45
67
 
46
68
  ## Words OpenTelemetry already has
47
69
 
@@ -503,7 +503,9 @@ var StorageSemanticsSchema = z9.object({
503
503
  * The table, bucket, collection, or index, as the source declares
504
504
  * it, or null when the source states one this reader could not
505
505
  * settle. A null container pairs with nothing, rather than with
506
- * whatever its source text happens to spell.
506
+ * whatever its source text happens to spell. The string is in the
507
+ * boundary-name syntax: a literal, a pattern with `{}` holes, or a
508
+ * reference, and `parseBoundaryName` is the one reader of it.
507
509
  */
508
510
  container: z9.string().nullable(),
509
511
  /**
package/dist/index.cjs CHANGED
@@ -43,6 +43,7 @@ __export(index_exports, {
43
43
  bodyShapesMatch: () => bodyShapesMatch,
44
44
  boundaryKey: () => boundaryKey,
45
45
  boundaryLabel: () => boundaryLabel,
46
+ boundaryNameString: () => boundaryNameString,
46
47
  busIdentityKey: () => busIdentityKey,
47
48
  busesAgree: () => busesAgree,
48
49
  canPair: () => canPair,
@@ -75,7 +76,12 @@ __export(index_exports, {
75
76
  normalizeRuleBoundary: () => normalizeRuleBoundary,
76
77
  packageExportBinding: () => packageExportBinding,
77
78
  pairingKey: () => pairingKey,
79
+ parseBoundaryName: () => parseBoundaryName,
78
80
  parseChannel: () => parseChannel,
81
+ patternHole: () => patternHole,
82
+ referenceFromName: () => referenceFromName,
83
+ referenceName: () => referenceName,
84
+ referenceOf: () => referenceOf,
79
85
  reportsUnpairedItself: () => reportsUnpairedItself,
80
86
  restBinding: () => restBinding,
81
87
  routePathAdmits: () => routePathAdmits,
@@ -596,7 +602,9 @@ var StorageSemanticsSchema = import_zod9.z.object({
596
602
  * The table, bucket, collection, or index, as the source declares
597
603
  * it, or null when the source states one this reader could not
598
604
  * settle. A null container pairs with nothing, rather than with
599
- * whatever its source text happens to spell.
605
+ * whatever its source text happens to spell. The string is in the
606
+ * boundary-name syntax: a literal, a pattern with `{}` holes, or a
607
+ * reference, and `parseBoundaryName` is the one reader of it.
600
608
  */
601
609
  container: import_zod9.z.string().nullable(),
602
610
  /**
@@ -858,29 +866,47 @@ function servesRequest(binding, method, path) {
858
866
  return serves(binding.semantics, method, path);
859
867
  }
860
868
 
861
- // src/codeScope.ts
862
- function codeScopePath(raw) {
863
- return raw.trim().replace(/^\.\//, "").replace(/\/+$/, "");
864
- }
865
- function fileInCodeScope(file, scope) {
866
- const prefix = codeScopePath(scope);
867
- if (prefix === "" || prefix === ".") {
868
- return true;
869
- }
870
- const path = codeScopePath(file);
871
- return path === prefix || path.startsWith(`${prefix}/`);
872
- }
873
-
874
869
  // src/dispatch.ts
875
870
  function dispatchByType(table, value) {
876
871
  const handler = table[value.type];
877
872
  return handler(value);
878
873
  }
879
874
 
880
- // src/namePattern.ts
881
- var HOLE = /\{[^}]*\}/g;
875
+ // src/boundaryName.ts
876
+ var HOLE_SPLIT = /(\{[^}]*\})/;
882
877
  var SUB_TOKEN = /\$\{([^}]*)\}/g;
883
878
  var SUB_ESCAPE = /\$\{!/;
879
+ function isHole(piece) {
880
+ return piece.startsWith("{") && piece.endsWith("}");
881
+ }
882
+ function parseBoundaryName(name) {
883
+ const parts = name.split(HOLE_SPLIT).filter((piece) => piece !== "").map(
884
+ (piece) => isHole(piece) ? { type: "hole", label: piece.slice(1, -1) } : { type: "text", text: piece }
885
+ );
886
+ const only = parts.length === 1 ? parts[0] : void 0;
887
+ if (only !== void 0 && only.type === "hole") {
888
+ return { type: "reference", path: only.label.split(".") };
889
+ }
890
+ if (parts.some((part) => part.type === "hole")) {
891
+ return { type: "pattern", parts };
892
+ }
893
+ return { type: "literal", value: name };
894
+ }
895
+ var partString = {
896
+ text: (part) => part.text,
897
+ hole: (part) => patternHole(part.label)
898
+ };
899
+ var nameString = {
900
+ literal: (name) => name.value,
901
+ pattern: (name) => name.parts.map((part) => dispatchByType(partString, part)).join(""),
902
+ reference: (name) => patternHole(name.path.join("."))
903
+ };
904
+ function boundaryNameString(name) {
905
+ return dispatchByType(nameString, name);
906
+ }
907
+ function patternHole(label) {
908
+ return `{${label}}`;
909
+ }
884
910
  function namePatternFromSub(value) {
885
911
  if (Array.isArray(value)) {
886
912
  return typeof value[0] === "string" ? namePatternFromSub(value[0]) : null;
@@ -891,56 +917,101 @@ function namePatternFromSub(value) {
891
917
  if (SUB_ESCAPE.test(value)) {
892
918
  return null;
893
919
  }
894
- return value.replace(SUB_TOKEN, (_whole, inner) => `{${inner}}`);
920
+ return value.replace(
921
+ SUB_TOKEN,
922
+ (_whole, inner) => patternHole(inner)
923
+ );
895
924
  }
896
925
  function namesNothing(name) {
897
- return /^\{[^}]*\}$/.test(name);
926
+ return parseBoundaryName(name).type === "reference";
898
927
  }
899
928
  function hasNameHole(name) {
900
- HOLE.lastIndex = 0;
901
- return HOLE.test(name);
929
+ return parseBoundaryName(name).type !== "literal";
902
930
  }
903
931
  function namePatternKey(name) {
904
- return name.replace(HOLE, "{}");
932
+ return dispatchByType(
933
+ {
934
+ literal: (parsed) => parsed.value,
935
+ pattern: (parsed) => boundaryNameString({
936
+ type: "pattern",
937
+ parts: parsed.parts.map(
938
+ (part) => part.type === "hole" ? { type: "hole", label: "" } : part
939
+ )
940
+ }),
941
+ reference: () => patternHole("")
942
+ },
943
+ parseBoundaryName(name)
944
+ );
905
945
  }
906
946
  function fixedTextLength(name) {
907
- return name.replace(HOLE, "").length;
908
- }
909
- function isHole(part) {
910
- return part.startsWith("{") && part.endsWith("}");
947
+ return dispatchByType(
948
+ {
949
+ literal: (parsed) => parsed.value.length,
950
+ pattern: (parsed) => parsed.parts.reduce(
951
+ (total, part) => total + (part.type === "text" ? part.text.length : 0),
952
+ 0
953
+ ),
954
+ reference: () => 0
955
+ },
956
+ parseBoundaryName(name)
957
+ );
911
958
  }
912
959
  function quote(text) {
913
960
  return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
914
961
  }
915
- function holeSource(_following) {
916
- return ".+";
917
- }
918
962
  function admits(pattern, name) {
919
- const parts = pattern.split(/(\{[^}]*\})/);
920
- const source = parts.map(
921
- (part, index) => isHole(part) ? holeSource(parts[index + 1] ?? "") : quote(part)
922
- ).join("");
963
+ const source = pattern.parts.map((part) => part.type === "hole" ? ".+" : quote(part.text)).join("");
923
964
  return new RegExp(`^${source}$`).test(name);
924
965
  }
925
966
  function namesAgree(a, b) {
926
- if (namesNothing(a) || namesNothing(b)) {
967
+ const left = parseBoundaryName(a);
968
+ const right = parseBoundaryName(b);
969
+ if (left.type === "reference" || right.type === "reference") {
927
970
  return false;
928
971
  }
929
- if (a === b) {
930
- return true;
931
- }
932
- const aHasHole = hasNameHole(a);
933
- const bHasHole = hasNameHole(b);
934
- if (aHasHole && bHasHole) {
972
+ if (left.type === "pattern" && right.type === "pattern") {
935
973
  return namePatternKey(a) === namePatternKey(b);
936
974
  }
937
- if (aHasHole) {
938
- return admits(a, b);
975
+ if (left.type === "pattern") {
976
+ return admits(left, b);
939
977
  }
940
- if (bHasHole) {
941
- return admits(b, a);
978
+ if (right.type === "pattern") {
979
+ return admits(right, a);
942
980
  }
943
- return false;
981
+ return a === b;
982
+ }
983
+ function referenceName(reference) {
984
+ const path = [reference.root, ...reference.fields];
985
+ if (path.some((part) => part === "")) {
986
+ return null;
987
+ }
988
+ return boundaryNameString({ type: "reference", path });
989
+ }
990
+ function referenceOf(name) {
991
+ if (name.type !== "reference") {
992
+ return null;
993
+ }
994
+ const root = name.path[0];
995
+ if (root === void 0 || name.path.some((part) => part === "")) {
996
+ return null;
997
+ }
998
+ return { root, fields: name.path.slice(1) };
999
+ }
1000
+ function referenceFromName(name) {
1001
+ return referenceOf(parseBoundaryName(name));
1002
+ }
1003
+
1004
+ // src/codeScope.ts
1005
+ function codeScopePath(raw) {
1006
+ return raw.trim().replace(/^\.\//, "").replace(/\/+$/, "");
1007
+ }
1008
+ function fileInCodeScope(file, scope) {
1009
+ const prefix = codeScopePath(scope);
1010
+ if (prefix === "" || prefix === ".") {
1011
+ return true;
1012
+ }
1013
+ const path = codeScopePath(file);
1014
+ return path === prefix || path.startsWith(`${prefix}/`);
944
1015
  }
945
1016
 
946
1017
  // src/suppressions.ts
@@ -1343,6 +1414,7 @@ function metricBinding(opts) {
1343
1414
  bodyShapesMatch,
1344
1415
  boundaryKey,
1345
1416
  boundaryLabel,
1417
+ boundaryNameString,
1346
1418
  busIdentityKey,
1347
1419
  busesAgree,
1348
1420
  canPair,
@@ -1375,7 +1447,12 @@ function metricBinding(opts) {
1375
1447
  normalizeRuleBoundary,
1376
1448
  packageExportBinding,
1377
1449
  pairingKey,
1450
+ parseBoundaryName,
1378
1451
  parseChannel,
1452
+ patternHole,
1453
+ referenceFromName,
1454
+ referenceName,
1455
+ referenceOf,
1379
1456
  reportsUnpairedItself,
1380
1457
  restBinding,
1381
1458
  routePathAdmits,
package/dist/index.d.cts CHANGED
@@ -243,6 +243,115 @@ declare function reportsUnpairedItself(binding: BoundaryBinding): boolean;
243
243
  */
244
244
  declare function servesRequest(binding: BoundaryBinding, method: string, path: string): MatchResult | null;
245
245
 
246
+ /**
247
+ * The boundary-name syntax, parsed and printed in one place.
248
+ *
249
+ * A name in a summary means one of three things, told apart by the
250
+ * braces alone: a literal (`orders-v1`), a pattern with deploy-time
251
+ * holes (`{stage}-orders-v1`), or a reference saying where to go and
252
+ * ask (`{location.bucket}`, `{ORDER_TABLE}`). A second parser can
253
+ * disagree about which is which, and a second printer can spell a
254
+ * value this one cannot read back, so everything that touches the
255
+ * braces calls this module: `parseBoundaryName` and
256
+ * `boundaryNameString` are the two directions, and the helpers below
257
+ * are views over them. The package README tells the longer story,
258
+ * including why REST route paths and message-bus channels stay apart.
259
+ */
260
+ /** One piece of a pattern: text the writer stated, or a hole. */
261
+ type NamePart = {
262
+ type: "text";
263
+ text: string;
264
+ } | {
265
+ type: "hole";
266
+ label: string;
267
+ };
268
+ /**
269
+ * What a name string means. A `reference` keeps its raw dot-separated
270
+ * path, and `referenceOf` is the view that checks the parts are all
271
+ * present, so a malformed spelling still classifies as a reference and
272
+ * still pairs with nothing.
273
+ */
274
+ type BoundaryName = {
275
+ type: "literal";
276
+ value: string;
277
+ } | {
278
+ type: "pattern";
279
+ parts: NamePart[];
280
+ } | {
281
+ type: "reference";
282
+ path: string[];
283
+ };
284
+ /** What a name string means. The exact inverse of `boundaryNameString`. */
285
+ declare function parseBoundaryName(name: string): BoundaryName;
286
+ /** The one serializer. Everything a summary spells goes through here. */
287
+ declare function boundaryNameString(name: BoundaryName): string;
288
+ /**
289
+ * How a hole is spelled. A reader that assembles a pattern a part at a
290
+ * time, the way the adapter's name reader does, mints each hole here
291
+ * so the spelling cannot drift from the parse above.
292
+ */
293
+ declare function patternHole(label: string): string;
294
+ /**
295
+ * The name a CloudFormation `Fn::Sub` value states. The array form
296
+ * takes its template from the first element, and the variable map only
297
+ * says where a substitution comes from, which does not change the name.
298
+ */
299
+ declare function namePatternFromSub(value: unknown): string | null;
300
+ /**
301
+ * Whether a name says only that somebody else knows it. A wrapper that
302
+ * takes its bucket as an argument states `{location.bucket}` and
303
+ * nothing more, which says which parameter to ask rather than which
304
+ * bucket. A name like that agrees with nothing until something grounds
305
+ * it, since otherwise it would agree with every name there is.
306
+ */
307
+ declare function namesNothing(name: string): boolean;
308
+ /** Whether a name has anything a source left for deploy time to fill. */
309
+ declare function hasNameHole(name: string): boolean;
310
+ /**
311
+ * The form two patterns are compared in: every hole reduced to the same
312
+ * token, since the two sides pick their own name for the parameter.
313
+ */
314
+ declare function namePatternKey(name: string): string;
315
+ /** How much of a name the writer stated rather than left for deploy time. */
316
+ declare function fixedTextLength(name: string): number;
317
+ /**
318
+ * Whether two names are the same name. Two patterns agree when their
319
+ * fixed parts line up, since a hole on one side meets a hole on the
320
+ * other. A pattern and a concrete name agree when the fixed parts are
321
+ * where the pattern says they are, which is what happens when one side
322
+ * hardcoded what the other parameterized. A reference agrees with
323
+ * nothing on either side.
324
+ */
325
+ declare function namesAgree(a: string, b: string): boolean;
326
+ /** Where a reference says to go and ask. */
327
+ interface Reference {
328
+ /**
329
+ * The value the code starts from: a parameter of the unit the
330
+ * reference was written in, or a variable the deployment sets. Which
331
+ * of those it is depends on the unit's inputs, so the reader that
332
+ * grounds a reference decides, not the string.
333
+ */
334
+ root: string;
335
+ /** The fields to read inside it, outermost first. */
336
+ fields: string[];
337
+ }
338
+ /**
339
+ * How a reference is written. Null when a part of it is empty, since a
340
+ * reference has to say what to ask about.
341
+ */
342
+ declare function referenceName(reference: Reference): string | null;
343
+ /**
344
+ * The place a parsed reference says to ask, or null for a name that is
345
+ * not a reference, or for a reference with a part missing, which says
346
+ * nothing anybody could answer.
347
+ */
348
+ declare function referenceOf(name: BoundaryName): Reference | null;
349
+ /**
350
+ * The reference a name states, or null when the name states a name
351
+ * rather than where to go and ask.
352
+ */
353
+ declare function referenceFromName(name: string): Reference | null;
354
+
246
355
  /**
247
356
  * Deciding when two message-bus channel strings mean the same thing.
248
357
  *
@@ -325,53 +434,6 @@ declare function dispatchByType<T extends {
325
434
  type: string;
326
435
  }, R>(table: DispatchTable<T, R>, value: T): R;
327
436
 
328
- /**
329
- * A name two sides state with a hole in it.
330
- *
331
- * A deployed resource is often called something built at deploy time:
332
- * a template writes `!Sub "${StageName}-orders-v1"` and the code writes
333
- * `` `${stage}-orders-v1` ``. Neither side states a string, both agree
334
- * about the fixed text, and each spells the parameter its own way. So a
335
- * name is written here as fixed text with `{}` holes, and two names
336
- * agree when their fixed parts line up.
337
- *
338
- * A hole covers a whole parameter rather than one piece of a name,
339
- * because a name has no separator every project agrees on. Two
340
- * patterns that both cover one name are told apart by how much fixed
341
- * text each states, rather than by narrowing what a hole can cover.
342
- */
343
- /**
344
- * The name a CloudFormation `Fn::Sub` value states. The array form
345
- * takes its template from the first element, and the variable map only
346
- * says where a substitution comes from, which does not change the name.
347
- */
348
- declare function namePatternFromSub(value: unknown): string | null;
349
- /**
350
- * Whether a name says only that somebody else knows it. A wrapper that
351
- * takes its bucket as an argument states `{location.bucket}` and
352
- * nothing more, which says which parameter to ask rather than which
353
- * bucket. A name like that agrees with nothing until something grounds
354
- * it, since otherwise it would agree with every name there is.
355
- */
356
- declare function namesNothing(name: string): boolean;
357
- /** Whether a name has anything a source left for deploy time to fill. */
358
- declare function hasNameHole(name: string): boolean;
359
- /**
360
- * The form two patterns are compared in: every hole reduced to the same
361
- * token, since the two sides pick their own name for the parameter.
362
- */
363
- declare function namePatternKey(name: string): string;
364
- /** How much of a name the writer stated rather than left for deploy time. */
365
- declare function fixedTextLength(name: string): number;
366
- /**
367
- * Whether two names are the same name. Two patterns agree when their
368
- * fixed parts line up, since a hole on one side meets a hole on the
369
- * other. A pattern and a concrete name agree when the fixed parts are
370
- * where the pattern says they are, which is what happens when one side
371
- * hardcoded what the other parameterized.
372
- */
373
- declare function namesAgree(a: string, b: string): boolean;
374
-
375
437
  /**
376
438
  * The suppression pipeline, over the smallest finding both checkers share.
377
439
  *
@@ -622,4 +684,4 @@ declare function metricBinding(opts: {
622
684
  metricType: string | null;
623
685
  }): BoundaryBinding;
624
686
 
625
- export { type BoundaryBehavior, type BoundaryBinding, BoundaryBindingSchema, type BoundarySemanticsDefinition, type BusIdentityKey, type Channel, type ConfidenceInfo, ConfidenceInfoSchema, type ConfidenceLevel, ConfidenceLevelSchema, type ConfidenceSource, ConfidenceSourceSchema, type Corroboration, CorroborationSchema, DeployableUnit, type DispatchTable, type FindingSuppression, type FnIdentityKey, type GqlIdentityKey, type MatchResult, MessageBusSemantics, MessageBusTechnology, type MetricIdentityKey, type MetricSemantics, MetricSemanticsSchema, type ParsedChannel, Semantics, type SemconvAttribute, type SemconvMapping, type SourceLocation, SourceLocationSchema, type SuppressableSeverity, type SuppressibleFinding, type SuppressionFile, SuppressionFileSchema, type SuppressionRule, SuppressionRuleSchema, TypeShape, applySuppressionsToFindings, bodyShapesMatch, boundaryKey, boundaryLabel, busIdentityKey, busesAgree, canPair, channelsPair, codeScopePath, countsForThreshold, dispatchByType, displayLabel, exchangesHttpResponses, fileInCodeScope, fixedTextLength, fnIdentityKey, formatChannel, functionCallBinding, gqlIdentityKey, graphqlOperationBinding, graphqlResolverBinding, hasNameHole, isGraphqlOperationBinding, messageBusBinding, metricBinding, metricIdentityKey, namePatternFromSub, namePatternKey, namesAgree, namesNothing, normalizeRuleBoundary, packageExportBinding, pairingKey, parseChannel, reportsUnpairedItself, restBinding, ruleBoundaryMatchesKey, runtimeConfigBinding, semanticsAgree, semconvAttributes, servesRequest, storageBinding, validateRule };
687
+ export { type BoundaryBehavior, type BoundaryBinding, BoundaryBindingSchema, type BoundaryName, type BoundarySemanticsDefinition, type BusIdentityKey, type Channel, type ConfidenceInfo, ConfidenceInfoSchema, type ConfidenceLevel, ConfidenceLevelSchema, type ConfidenceSource, ConfidenceSourceSchema, type Corroboration, CorroborationSchema, DeployableUnit, type DispatchTable, type FindingSuppression, type FnIdentityKey, type GqlIdentityKey, type MatchResult, MessageBusSemantics, MessageBusTechnology, type MetricIdentityKey, type MetricSemantics, MetricSemanticsSchema, type NamePart, type ParsedChannel, type Reference, Semantics, type SemconvAttribute, type SemconvMapping, type SourceLocation, SourceLocationSchema, type SuppressableSeverity, type SuppressibleFinding, type SuppressionFile, SuppressionFileSchema, type SuppressionRule, SuppressionRuleSchema, TypeShape, applySuppressionsToFindings, bodyShapesMatch, boundaryKey, boundaryLabel, boundaryNameString, busIdentityKey, busesAgree, canPair, channelsPair, codeScopePath, countsForThreshold, dispatchByType, displayLabel, exchangesHttpResponses, fileInCodeScope, fixedTextLength, fnIdentityKey, formatChannel, functionCallBinding, gqlIdentityKey, graphqlOperationBinding, graphqlResolverBinding, hasNameHole, isGraphqlOperationBinding, messageBusBinding, metricBinding, metricIdentityKey, namePatternFromSub, namePatternKey, namesAgree, namesNothing, normalizeRuleBoundary, packageExportBinding, pairingKey, parseBoundaryName, parseChannel, patternHole, referenceFromName, referenceName, referenceOf, reportsUnpairedItself, restBinding, ruleBoundaryMatchesKey, runtimeConfigBinding, semanticsAgree, semconvAttributes, servesRequest, storageBinding, validateRule };
package/dist/index.d.ts CHANGED
@@ -243,6 +243,115 @@ declare function reportsUnpairedItself(binding: BoundaryBinding): boolean;
243
243
  */
244
244
  declare function servesRequest(binding: BoundaryBinding, method: string, path: string): MatchResult | null;
245
245
 
246
+ /**
247
+ * The boundary-name syntax, parsed and printed in one place.
248
+ *
249
+ * A name in a summary means one of three things, told apart by the
250
+ * braces alone: a literal (`orders-v1`), a pattern with deploy-time
251
+ * holes (`{stage}-orders-v1`), or a reference saying where to go and
252
+ * ask (`{location.bucket}`, `{ORDER_TABLE}`). A second parser can
253
+ * disagree about which is which, and a second printer can spell a
254
+ * value this one cannot read back, so everything that touches the
255
+ * braces calls this module: `parseBoundaryName` and
256
+ * `boundaryNameString` are the two directions, and the helpers below
257
+ * are views over them. The package README tells the longer story,
258
+ * including why REST route paths and message-bus channels stay apart.
259
+ */
260
+ /** One piece of a pattern: text the writer stated, or a hole. */
261
+ type NamePart = {
262
+ type: "text";
263
+ text: string;
264
+ } | {
265
+ type: "hole";
266
+ label: string;
267
+ };
268
+ /**
269
+ * What a name string means. A `reference` keeps its raw dot-separated
270
+ * path, and `referenceOf` is the view that checks the parts are all
271
+ * present, so a malformed spelling still classifies as a reference and
272
+ * still pairs with nothing.
273
+ */
274
+ type BoundaryName = {
275
+ type: "literal";
276
+ value: string;
277
+ } | {
278
+ type: "pattern";
279
+ parts: NamePart[];
280
+ } | {
281
+ type: "reference";
282
+ path: string[];
283
+ };
284
+ /** What a name string means. The exact inverse of `boundaryNameString`. */
285
+ declare function parseBoundaryName(name: string): BoundaryName;
286
+ /** The one serializer. Everything a summary spells goes through here. */
287
+ declare function boundaryNameString(name: BoundaryName): string;
288
+ /**
289
+ * How a hole is spelled. A reader that assembles a pattern a part at a
290
+ * time, the way the adapter's name reader does, mints each hole here
291
+ * so the spelling cannot drift from the parse above.
292
+ */
293
+ declare function patternHole(label: string): string;
294
+ /**
295
+ * The name a CloudFormation `Fn::Sub` value states. The array form
296
+ * takes its template from the first element, and the variable map only
297
+ * says where a substitution comes from, which does not change the name.
298
+ */
299
+ declare function namePatternFromSub(value: unknown): string | null;
300
+ /**
301
+ * Whether a name says only that somebody else knows it. A wrapper that
302
+ * takes its bucket as an argument states `{location.bucket}` and
303
+ * nothing more, which says which parameter to ask rather than which
304
+ * bucket. A name like that agrees with nothing until something grounds
305
+ * it, since otherwise it would agree with every name there is.
306
+ */
307
+ declare function namesNothing(name: string): boolean;
308
+ /** Whether a name has anything a source left for deploy time to fill. */
309
+ declare function hasNameHole(name: string): boolean;
310
+ /**
311
+ * The form two patterns are compared in: every hole reduced to the same
312
+ * token, since the two sides pick their own name for the parameter.
313
+ */
314
+ declare function namePatternKey(name: string): string;
315
+ /** How much of a name the writer stated rather than left for deploy time. */
316
+ declare function fixedTextLength(name: string): number;
317
+ /**
318
+ * Whether two names are the same name. Two patterns agree when their
319
+ * fixed parts line up, since a hole on one side meets a hole on the
320
+ * other. A pattern and a concrete name agree when the fixed parts are
321
+ * where the pattern says they are, which is what happens when one side
322
+ * hardcoded what the other parameterized. A reference agrees with
323
+ * nothing on either side.
324
+ */
325
+ declare function namesAgree(a: string, b: string): boolean;
326
+ /** Where a reference says to go and ask. */
327
+ interface Reference {
328
+ /**
329
+ * The value the code starts from: a parameter of the unit the
330
+ * reference was written in, or a variable the deployment sets. Which
331
+ * of those it is depends on the unit's inputs, so the reader that
332
+ * grounds a reference decides, not the string.
333
+ */
334
+ root: string;
335
+ /** The fields to read inside it, outermost first. */
336
+ fields: string[];
337
+ }
338
+ /**
339
+ * How a reference is written. Null when a part of it is empty, since a
340
+ * reference has to say what to ask about.
341
+ */
342
+ declare function referenceName(reference: Reference): string | null;
343
+ /**
344
+ * The place a parsed reference says to ask, or null for a name that is
345
+ * not a reference, or for a reference with a part missing, which says
346
+ * nothing anybody could answer.
347
+ */
348
+ declare function referenceOf(name: BoundaryName): Reference | null;
349
+ /**
350
+ * The reference a name states, or null when the name states a name
351
+ * rather than where to go and ask.
352
+ */
353
+ declare function referenceFromName(name: string): Reference | null;
354
+
246
355
  /**
247
356
  * Deciding when two message-bus channel strings mean the same thing.
248
357
  *
@@ -325,53 +434,6 @@ declare function dispatchByType<T extends {
325
434
  type: string;
326
435
  }, R>(table: DispatchTable<T, R>, value: T): R;
327
436
 
328
- /**
329
- * A name two sides state with a hole in it.
330
- *
331
- * A deployed resource is often called something built at deploy time:
332
- * a template writes `!Sub "${StageName}-orders-v1"` and the code writes
333
- * `` `${stage}-orders-v1` ``. Neither side states a string, both agree
334
- * about the fixed text, and each spells the parameter its own way. So a
335
- * name is written here as fixed text with `{}` holes, and two names
336
- * agree when their fixed parts line up.
337
- *
338
- * A hole covers a whole parameter rather than one piece of a name,
339
- * because a name has no separator every project agrees on. Two
340
- * patterns that both cover one name are told apart by how much fixed
341
- * text each states, rather than by narrowing what a hole can cover.
342
- */
343
- /**
344
- * The name a CloudFormation `Fn::Sub` value states. The array form
345
- * takes its template from the first element, and the variable map only
346
- * says where a substitution comes from, which does not change the name.
347
- */
348
- declare function namePatternFromSub(value: unknown): string | null;
349
- /**
350
- * Whether a name says only that somebody else knows it. A wrapper that
351
- * takes its bucket as an argument states `{location.bucket}` and
352
- * nothing more, which says which parameter to ask rather than which
353
- * bucket. A name like that agrees with nothing until something grounds
354
- * it, since otherwise it would agree with every name there is.
355
- */
356
- declare function namesNothing(name: string): boolean;
357
- /** Whether a name has anything a source left for deploy time to fill. */
358
- declare function hasNameHole(name: string): boolean;
359
- /**
360
- * The form two patterns are compared in: every hole reduced to the same
361
- * token, since the two sides pick their own name for the parameter.
362
- */
363
- declare function namePatternKey(name: string): string;
364
- /** How much of a name the writer stated rather than left for deploy time. */
365
- declare function fixedTextLength(name: string): number;
366
- /**
367
- * Whether two names are the same name. Two patterns agree when their
368
- * fixed parts line up, since a hole on one side meets a hole on the
369
- * other. A pattern and a concrete name agree when the fixed parts are
370
- * where the pattern says they are, which is what happens when one side
371
- * hardcoded what the other parameterized.
372
- */
373
- declare function namesAgree(a: string, b: string): boolean;
374
-
375
437
  /**
376
438
  * The suppression pipeline, over the smallest finding both checkers share.
377
439
  *
@@ -622,4 +684,4 @@ declare function metricBinding(opts: {
622
684
  metricType: string | null;
623
685
  }): BoundaryBinding;
624
686
 
625
- export { type BoundaryBehavior, type BoundaryBinding, BoundaryBindingSchema, type BoundarySemanticsDefinition, type BusIdentityKey, type Channel, type ConfidenceInfo, ConfidenceInfoSchema, type ConfidenceLevel, ConfidenceLevelSchema, type ConfidenceSource, ConfidenceSourceSchema, type Corroboration, CorroborationSchema, DeployableUnit, type DispatchTable, type FindingSuppression, type FnIdentityKey, type GqlIdentityKey, type MatchResult, MessageBusSemantics, MessageBusTechnology, type MetricIdentityKey, type MetricSemantics, MetricSemanticsSchema, type ParsedChannel, Semantics, type SemconvAttribute, type SemconvMapping, type SourceLocation, SourceLocationSchema, type SuppressableSeverity, type SuppressibleFinding, type SuppressionFile, SuppressionFileSchema, type SuppressionRule, SuppressionRuleSchema, TypeShape, applySuppressionsToFindings, bodyShapesMatch, boundaryKey, boundaryLabel, busIdentityKey, busesAgree, canPair, channelsPair, codeScopePath, countsForThreshold, dispatchByType, displayLabel, exchangesHttpResponses, fileInCodeScope, fixedTextLength, fnIdentityKey, formatChannel, functionCallBinding, gqlIdentityKey, graphqlOperationBinding, graphqlResolverBinding, hasNameHole, isGraphqlOperationBinding, messageBusBinding, metricBinding, metricIdentityKey, namePatternFromSub, namePatternKey, namesAgree, namesNothing, normalizeRuleBoundary, packageExportBinding, pairingKey, parseChannel, reportsUnpairedItself, restBinding, ruleBoundaryMatchesKey, runtimeConfigBinding, semanticsAgree, semconvAttributes, servesRequest, storageBinding, validateRule };
687
+ export { type BoundaryBehavior, type BoundaryBinding, BoundaryBindingSchema, type BoundaryName, type BoundarySemanticsDefinition, type BusIdentityKey, type Channel, type ConfidenceInfo, ConfidenceInfoSchema, type ConfidenceLevel, ConfidenceLevelSchema, type ConfidenceSource, ConfidenceSourceSchema, type Corroboration, CorroborationSchema, DeployableUnit, type DispatchTable, type FindingSuppression, type FnIdentityKey, type GqlIdentityKey, type MatchResult, MessageBusSemantics, MessageBusTechnology, type MetricIdentityKey, type MetricSemantics, MetricSemanticsSchema, type NamePart, type ParsedChannel, type Reference, Semantics, type SemconvAttribute, type SemconvMapping, type SourceLocation, SourceLocationSchema, type SuppressableSeverity, type SuppressibleFinding, type SuppressionFile, SuppressionFileSchema, type SuppressionRule, SuppressionRuleSchema, TypeShape, applySuppressionsToFindings, bodyShapesMatch, boundaryKey, boundaryLabel, boundaryNameString, busIdentityKey, busesAgree, canPair, channelsPair, codeScopePath, countsForThreshold, dispatchByType, displayLabel, exchangesHttpResponses, fileInCodeScope, fixedTextLength, fnIdentityKey, formatChannel, functionCallBinding, gqlIdentityKey, graphqlOperationBinding, graphqlResolverBinding, hasNameHole, isGraphqlOperationBinding, messageBusBinding, metricBinding, metricIdentityKey, namePatternFromSub, namePatternKey, namesAgree, namesNothing, normalizeRuleBoundary, packageExportBinding, pairingKey, parseBoundaryName, parseChannel, patternHole, referenceFromName, referenceName, referenceOf, reportsUnpairedItself, restBinding, ruleBoundaryMatchesKey, runtimeConfigBinding, semanticsAgree, semconvAttributes, servesRequest, storageBinding, validateRule };
package/dist/index.js CHANGED
@@ -33,7 +33,7 @@ import {
33
33
  semconvMappingOf,
34
34
  typeDefinitionKey,
35
35
  withDefinitionsInlined
36
- } from "./chunk-XUXN6DIX.js";
36
+ } from "./chunk-HBAMPBAO.js";
37
37
 
38
38
  // src/semconv.ts
39
39
  function semconvAttributes(binding) {
@@ -106,29 +106,47 @@ function servesRequest(binding, method, path) {
106
106
  return serves(binding.semantics, method, path);
107
107
  }
108
108
 
109
- // src/codeScope.ts
110
- function codeScopePath(raw) {
111
- return raw.trim().replace(/^\.\//, "").replace(/\/+$/, "");
112
- }
113
- function fileInCodeScope(file, scope) {
114
- const prefix = codeScopePath(scope);
115
- if (prefix === "" || prefix === ".") {
116
- return true;
117
- }
118
- const path = codeScopePath(file);
119
- return path === prefix || path.startsWith(`${prefix}/`);
120
- }
121
-
122
109
  // src/dispatch.ts
123
110
  function dispatchByType(table, value) {
124
111
  const handler = table[value.type];
125
112
  return handler(value);
126
113
  }
127
114
 
128
- // src/namePattern.ts
129
- var HOLE = /\{[^}]*\}/g;
115
+ // src/boundaryName.ts
116
+ var HOLE_SPLIT = /(\{[^}]*\})/;
130
117
  var SUB_TOKEN = /\$\{([^}]*)\}/g;
131
118
  var SUB_ESCAPE = /\$\{!/;
119
+ function isHole(piece) {
120
+ return piece.startsWith("{") && piece.endsWith("}");
121
+ }
122
+ function parseBoundaryName(name) {
123
+ const parts = name.split(HOLE_SPLIT).filter((piece) => piece !== "").map(
124
+ (piece) => isHole(piece) ? { type: "hole", label: piece.slice(1, -1) } : { type: "text", text: piece }
125
+ );
126
+ const only = parts.length === 1 ? parts[0] : void 0;
127
+ if (only !== void 0 && only.type === "hole") {
128
+ return { type: "reference", path: only.label.split(".") };
129
+ }
130
+ if (parts.some((part) => part.type === "hole")) {
131
+ return { type: "pattern", parts };
132
+ }
133
+ return { type: "literal", value: name };
134
+ }
135
+ var partString = {
136
+ text: (part) => part.text,
137
+ hole: (part) => patternHole(part.label)
138
+ };
139
+ var nameString = {
140
+ literal: (name) => name.value,
141
+ pattern: (name) => name.parts.map((part) => dispatchByType(partString, part)).join(""),
142
+ reference: (name) => patternHole(name.path.join("."))
143
+ };
144
+ function boundaryNameString(name) {
145
+ return dispatchByType(nameString, name);
146
+ }
147
+ function patternHole(label) {
148
+ return `{${label}}`;
149
+ }
132
150
  function namePatternFromSub(value) {
133
151
  if (Array.isArray(value)) {
134
152
  return typeof value[0] === "string" ? namePatternFromSub(value[0]) : null;
@@ -139,56 +157,101 @@ function namePatternFromSub(value) {
139
157
  if (SUB_ESCAPE.test(value)) {
140
158
  return null;
141
159
  }
142
- return value.replace(SUB_TOKEN, (_whole, inner) => `{${inner}}`);
160
+ return value.replace(
161
+ SUB_TOKEN,
162
+ (_whole, inner) => patternHole(inner)
163
+ );
143
164
  }
144
165
  function namesNothing(name) {
145
- return /^\{[^}]*\}$/.test(name);
166
+ return parseBoundaryName(name).type === "reference";
146
167
  }
147
168
  function hasNameHole(name) {
148
- HOLE.lastIndex = 0;
149
- return HOLE.test(name);
169
+ return parseBoundaryName(name).type !== "literal";
150
170
  }
151
171
  function namePatternKey(name) {
152
- return name.replace(HOLE, "{}");
172
+ return dispatchByType(
173
+ {
174
+ literal: (parsed) => parsed.value,
175
+ pattern: (parsed) => boundaryNameString({
176
+ type: "pattern",
177
+ parts: parsed.parts.map(
178
+ (part) => part.type === "hole" ? { type: "hole", label: "" } : part
179
+ )
180
+ }),
181
+ reference: () => patternHole("")
182
+ },
183
+ parseBoundaryName(name)
184
+ );
153
185
  }
154
186
  function fixedTextLength(name) {
155
- return name.replace(HOLE, "").length;
156
- }
157
- function isHole(part) {
158
- return part.startsWith("{") && part.endsWith("}");
187
+ return dispatchByType(
188
+ {
189
+ literal: (parsed) => parsed.value.length,
190
+ pattern: (parsed) => parsed.parts.reduce(
191
+ (total, part) => total + (part.type === "text" ? part.text.length : 0),
192
+ 0
193
+ ),
194
+ reference: () => 0
195
+ },
196
+ parseBoundaryName(name)
197
+ );
159
198
  }
160
199
  function quote(text) {
161
200
  return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
162
201
  }
163
- function holeSource(_following) {
164
- return ".+";
165
- }
166
202
  function admits(pattern, name) {
167
- const parts = pattern.split(/(\{[^}]*\})/);
168
- const source = parts.map(
169
- (part, index) => isHole(part) ? holeSource(parts[index + 1] ?? "") : quote(part)
170
- ).join("");
203
+ const source = pattern.parts.map((part) => part.type === "hole" ? ".+" : quote(part.text)).join("");
171
204
  return new RegExp(`^${source}$`).test(name);
172
205
  }
173
206
  function namesAgree(a, b) {
174
- if (namesNothing(a) || namesNothing(b)) {
207
+ const left = parseBoundaryName(a);
208
+ const right = parseBoundaryName(b);
209
+ if (left.type === "reference" || right.type === "reference") {
175
210
  return false;
176
211
  }
177
- if (a === b) {
178
- return true;
179
- }
180
- const aHasHole = hasNameHole(a);
181
- const bHasHole = hasNameHole(b);
182
- if (aHasHole && bHasHole) {
212
+ if (left.type === "pattern" && right.type === "pattern") {
183
213
  return namePatternKey(a) === namePatternKey(b);
184
214
  }
185
- if (aHasHole) {
186
- return admits(a, b);
215
+ if (left.type === "pattern") {
216
+ return admits(left, b);
187
217
  }
188
- if (bHasHole) {
189
- return admits(b, a);
218
+ if (right.type === "pattern") {
219
+ return admits(right, a);
190
220
  }
191
- return false;
221
+ return a === b;
222
+ }
223
+ function referenceName(reference) {
224
+ const path = [reference.root, ...reference.fields];
225
+ if (path.some((part) => part === "")) {
226
+ return null;
227
+ }
228
+ return boundaryNameString({ type: "reference", path });
229
+ }
230
+ function referenceOf(name) {
231
+ if (name.type !== "reference") {
232
+ return null;
233
+ }
234
+ const root = name.path[0];
235
+ if (root === void 0 || name.path.some((part) => part === "")) {
236
+ return null;
237
+ }
238
+ return { root, fields: name.path.slice(1) };
239
+ }
240
+ function referenceFromName(name) {
241
+ return referenceOf(parseBoundaryName(name));
242
+ }
243
+
244
+ // src/codeScope.ts
245
+ function codeScopePath(raw) {
246
+ return raw.trim().replace(/^\.\//, "").replace(/\/+$/, "");
247
+ }
248
+ function fileInCodeScope(file, scope) {
249
+ const prefix = codeScopePath(scope);
250
+ if (prefix === "" || prefix === ".") {
251
+ return true;
252
+ }
253
+ const path = codeScopePath(file);
254
+ return path === prefix || path.startsWith(`${prefix}/`);
192
255
  }
193
256
 
194
257
  // src/suppressions.ts
@@ -590,6 +653,7 @@ export {
590
653
  bodyShapesMatch,
591
654
  boundaryKey,
592
655
  boundaryLabel,
656
+ boundaryNameString,
593
657
  busIdentityKey,
594
658
  busesAgree,
595
659
  canPair,
@@ -622,7 +686,12 @@ export {
622
686
  normalizeRuleBoundary,
623
687
  packageExportBinding,
624
688
  pairingKey,
689
+ parseBoundaryName,
625
690
  parseChannel,
691
+ patternHole,
692
+ referenceFromName,
693
+ referenceName,
694
+ referenceOf,
626
695
  reportsUnpairedItself,
627
696
  restBinding,
628
697
  routePathAdmits,
package/dist/schemas.cjs CHANGED
@@ -535,7 +535,9 @@ var StorageSemanticsSchema = import_zod9.z.object({
535
535
  * The table, bucket, collection, or index, as the source declares
536
536
  * it, or null when the source states one this reader could not
537
537
  * settle. A null container pairs with nothing, rather than with
538
- * whatever its source text happens to spell.
538
+ * whatever its source text happens to spell. The string is in the
539
+ * boundary-name syntax: a literal, a pattern with `{}` holes, or a
540
+ * reference, and `parseBoundaryName` is the one reader of it.
539
541
  */
540
542
  container: import_zod9.z.string().nullable(),
541
543
  /**
package/dist/schemas.js CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  TypeShapeSchema,
18
18
  typeDefinitionKey,
19
19
  withDefinitionsInlined
20
- } from "./chunk-XUXN6DIX.js";
20
+ } from "./chunk-HBAMPBAO.js";
21
21
  export {
22
22
  BoundaryBindingSchema,
23
23
  ConfidenceInfoSchema,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suss/ir-core",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "description": "Shared IR primitives for suss — type shapes, boundary bindings, source locations, and confidence. Imported by both @suss/behavioral-ir and @suss/intent-ir so neither depends on the other.",
6
6
  "keywords": [