@servicenow/sdk-build-core 4.6.1 → 4.7.1

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 (53) hide show
  1. package/dist/compiler.d.ts +2 -0
  2. package/dist/compiler.js +13 -7
  3. package/dist/compiler.js.map +1 -1
  4. package/dist/index.d.ts +1 -0
  5. package/dist/index.js +1 -0
  6. package/dist/index.js.map +1 -1
  7. package/dist/now-config.d.ts +37 -0
  8. package/dist/now-config.js +9 -0
  9. package/dist/now-config.js.map +1 -1
  10. package/dist/package-inventory.d.ts +15 -0
  11. package/dist/package-inventory.js +59 -0
  12. package/dist/package-inventory.js.map +1 -0
  13. package/dist/plugins/context.d.ts +2 -2
  14. package/dist/plugins/index.d.ts +0 -1
  15. package/dist/plugins/index.js +0 -1
  16. package/dist/plugins/index.js.map +1 -1
  17. package/dist/plugins/plugin.d.ts +41 -53
  18. package/dist/plugins/plugin.js +502 -160
  19. package/dist/plugins/plugin.js.map +1 -1
  20. package/dist/plugins/shape.d.ts +13 -2
  21. package/dist/plugins/shape.js +96 -15
  22. package/dist/plugins/shape.js.map +1 -1
  23. package/dist/taxonomy.js +7 -2
  24. package/dist/taxonomy.js.map +1 -1
  25. package/dist/telemetry/clients/detect-agent.d.ts +4 -0
  26. package/dist/telemetry/clients/detect-agent.js +84 -0
  27. package/dist/telemetry/clients/detect-agent.js.map +1 -0
  28. package/dist/telemetry/clients/node-client.d.ts +2 -0
  29. package/dist/telemetry/clients/node-client.js +10 -9
  30. package/dist/telemetry/clients/node-client.js.map +1 -1
  31. package/dist/telemetry/index.d.ts +1 -1
  32. package/now.config.schema.json +19 -0
  33. package/package.json +9 -5
  34. package/src/compiler.ts +14 -7
  35. package/src/index.ts +1 -0
  36. package/src/now-config.ts +11 -0
  37. package/src/package-inventory.ts +75 -0
  38. package/src/plugins/context.ts +2 -2
  39. package/src/plugins/index.ts +0 -1
  40. package/src/plugins/plugin.ts +682 -228
  41. package/src/plugins/shape.ts +115 -24
  42. package/src/taxonomy.ts +8 -2
  43. package/src/telemetry/clients/detect-agent.ts +88 -0
  44. package/src/telemetry/clients/node-client.ts +12 -8
  45. package/src/telemetry/index.ts +1 -1
  46. package/dist/plugins/cache.d.ts +0 -15
  47. package/dist/plugins/cache.js +0 -22
  48. package/dist/plugins/cache.js.map +0 -1
  49. package/dist/plugins/usage.d.ts +0 -11
  50. package/dist/plugins/usage.js +0 -26
  51. package/dist/plugins/usage.js.map +0 -1
  52. package/src/plugins/cache.ts +0 -23
  53. package/src/plugins/usage.ts +0 -26
@@ -5,17 +5,26 @@ import {
5
5
  type SupportedNodeByKindName,
6
6
  ts,
7
7
  } from '../typescript'
8
- import { DeletedShape, type ObjectShape, type Record, type Shape, type ShapeClass, type StringShape } from './shape'
8
+ import {
9
+ CallExpressionShape,
10
+ DeletedShape,
11
+ type ObjectShape,
12
+ type Record,
13
+ type Shape,
14
+ type ShapeClass,
15
+ type StringShape,
16
+ VariableStatementShape,
17
+ } from './shape'
9
18
  import type { File, OutputFile } from './file'
10
19
  import type { Product } from './product'
11
20
  import { Database, DiffDatabase } from './database'
12
- import type { Context as BaseContext } from './context'
21
+ import type { Context as BaseContext, Diagnostics } from './context'
13
22
  import { getFileType, isSNScope } from '../util'
14
- import { Cache } from './cache'
15
23
  import { NOW_FILE_EXTENSION, NowConfig } from '..'
16
24
  import { path } from '@servicenow/sdk-build-core'
17
25
 
18
26
  type Context = Omit<BaseContext, 'keys'>
27
+ const FAILURE = { success: false } as const
19
28
 
20
29
  export type Result<Value = unknown> =
21
30
  | {
@@ -93,29 +102,6 @@ export type CoalesceStrategy =
93
102
 
94
103
  export type FileType = 'fluent' | 'module' | 'json' | 'unknown'
95
104
 
96
- export type PluginApiDoc = {
97
- /**
98
- * The name of the API (e.g., 'BusinessRule', 'Acl', 'Table')
99
- * This is the callee name used in fluent code.
100
- */
101
- apiName: string
102
-
103
- /**
104
- * Tags for categorizing and organizing the API documentation
105
- */
106
- tags: string[]
107
- }
108
-
109
- /**
110
- * Manifest structure for API documentation.
111
- */
112
- export type DocsManifest = {
113
- [apiName: string]: {
114
- docPath: string
115
- tags: string[]
116
- }
117
- }
118
-
119
105
  export type PluginConfig<
120
106
  Nodes extends SupportedKindName[] = SupportedKindName[],
121
107
  Shapes extends ShapeClass[] = ShapeClass[],
@@ -127,12 +113,6 @@ export type PluginConfig<
127
113
  */
128
114
  name: `${string}Plugin`
129
115
 
130
- /**
131
- * Documentation for the APIs provided by this plugin. This is used to automatically
132
- * generate documentation that can be retrieved via the getDocsMetadata() method.
133
- */
134
- docs: PluginApiDoc[]
135
-
136
116
  /**
137
117
  * The TypeScript AST nodes this plugin handles. Plugins that do not introduce new
138
118
  * syntax should not need to define any handlers here.
@@ -427,7 +407,7 @@ export type PluginConfig<
427
407
  /**
428
408
  * A regex pattern or predicate to apply to a file's path to determine if it should be handled by this plugin.
429
409
  */
430
- matcher?: RegExp | ((path: string, context: Context) => boolean | Promise<boolean>)
410
+ matcher?: RegExp | FileMatcher
431
411
 
432
412
  /**
433
413
  * Indicates whether this file should be parsed as an entry point when generating output. (Default: false)
@@ -444,6 +424,12 @@ export type PluginConfig<
444
424
  */
445
425
  toRecord?(file: File, context: Context): Result<Record> | Promise<Result<Record>>
446
426
  }[]
427
+
428
+ /**
429
+ * @deprecated This property is no longer read and will be removed in a
430
+ * future release.
431
+ */
432
+ docs?: any[]
447
433
  }
448
434
 
449
435
  function setCreator<V extends Product | Product[]>(creator: Plugin, result: Result<V>): Result<V> {
@@ -466,10 +452,229 @@ type SearchableReferences = {
466
452
  }
467
453
  }
468
454
 
455
+ /**
456
+ * Recursively sets `sys_policy` on a record and all its descendants,
457
+ * skipping any record that already has `sys_policy` set by the plugin.
458
+ */
459
+ function propagateSysPolicyToDescendants(record: Record, sysPolicyShape: Shape): Record {
460
+ const patched = record.get('sys_policy').isUndefined() ? record.merge({ sys_policy: sysPolicyShape }) : record
461
+
462
+ // flat() cannot substitute here: it collapses the entire descendant tree into a single array,
463
+ // discarding which records are direct children of which parent. This function rebuilds the
464
+ // hierarchy bottom-up via .with(), so it must operate one level at a time using getRelated().
465
+ const children = patched.getRelated()
466
+ if (children.length === 0) {
467
+ return patched
468
+ }
469
+
470
+ return patched.with(...children.map((child) => propagateSysPolicyToDescendants(child, sysPolicyShape)))
471
+ }
472
+
473
+ /**
474
+ * Auto-injects `sys_policy` into the record and all its descendants from the shape's
475
+ * `protectionPolicy` property, so that plugins don't need to explicitly map it.
476
+ * Only injects if `sys_policy` is not already set by the plugin, and only propagates
477
+ * if the root record's plugin did not already handle `sys_policy` itself.
478
+ */
479
+ function mergeSysPolicyFromShape(shape: Shape, record: Record): Record {
480
+ if (!(shape instanceof CallExpressionShape)) {
481
+ return record
482
+ }
483
+
484
+ const source = shape.getArgument(0).ifObject()
485
+ if (!source) {
486
+ return record
487
+ }
488
+
489
+ const protectionPolicy = source.get('protectionPolicy')
490
+ if (protectionPolicy.isUndefined()) {
491
+ return record
492
+ }
493
+
494
+ // If the plugin already mapped sys_policy on the root record, skip propagation entirely.
495
+ // The plugin owns the protectionPolicy handling in this case.
496
+ if (record.get('sys_policy').isDefined()) {
497
+ return record
498
+ }
499
+
500
+ return propagateSysPolicyToDescendants(record, protectionPolicy)
501
+ }
502
+
503
+ /**
504
+ * Auto-injects `protectionPolicy` into the shape from the record's `sys_policy` field,
505
+ * so that plugins don't need to explicitly map it. Only injects if `protectionPolicy` is
506
+ * not already set by the plugin.
507
+ */
508
+ function mergeProtectionPolicyFromRecord(record: Record, shape: Shape): Shape {
509
+ if (!(shape instanceof CallExpressionShape)) {
510
+ return shape
511
+ }
512
+
513
+ const firstArg = shape.getArgument(0).ifObject()
514
+ if (!firstArg || firstArg.get('protectionPolicy').isDefined()) {
515
+ return shape
516
+ }
517
+
518
+ const sysPolicy = record.get('sys_policy').ifString()?.getValue()
519
+ if (!sysPolicy) {
520
+ return shape
521
+ }
522
+
523
+ return new CallExpressionShape({
524
+ source: shape.getSource(),
525
+ callee: shape.getCallee(),
526
+ args: [firstArg.merge({ protectionPolicy: sysPolicy }), ...shape.getArguments().slice(1)],
527
+ exportName: shape.getExportName(),
528
+ })
529
+ }
530
+
531
+ /**
532
+ * Auto-injects `$meta.installMethod` into the shape based on the record's install category.
533
+ * When transforming from `unload.demo/`, `unload/`, or `apply_once/` directories, the generated
534
+ * Fluent code should include `$meta: { installMethod: 'demo' | 'first install' | 'once' }`.
535
+ * Only injects if `$meta.installMethod` is not already set by the plugin.
536
+ */
537
+ function mergeMetaFromRecord(record: Record, shape: Shape): Shape {
538
+ if (!(shape instanceof CallExpressionShape)) {
539
+ return shape
540
+ }
541
+
542
+ const firstArg = shape.getArgument(0).ifObject()
543
+ const existingMeta = firstArg?.get('$meta').ifObject()
544
+ if (!firstArg || existingMeta?.get('installMethod').isDefined()) {
545
+ return shape
546
+ }
547
+
548
+ const installCategory = record.getInstallCategory()
549
+ let installMethod: 'demo' | 'first install' | 'once' | undefined
550
+ if (installCategory === 'unload.demo') {
551
+ installMethod = 'demo'
552
+ } else if (installCategory === 'unload') {
553
+ installMethod = 'first install'
554
+ } else if (installCategory === 'apply_once') {
555
+ installMethod = 'once'
556
+ }
557
+
558
+ if (!installMethod) {
559
+ return shape
560
+ }
561
+
562
+ return new CallExpressionShape({
563
+ source: shape.getSource(),
564
+ callee: shape.getCallee(),
565
+ args: [firstArg.merge({ $meta: { installMethod } }), ...shape.getArguments().slice(1)],
566
+ exportName: shape.getExportName(),
567
+ })
568
+ }
569
+
570
+ /**
571
+ * Injects `$override` properties from the Fluent shape into the record, so plugins don't
572
+ * need to explicitly map them. Only injects fields not already set by the plugin.
573
+ */
574
+ function mergeOverrideFromShape(shape: Shape, record: Record, diagnostics: Diagnostics): Record {
575
+ if (!(shape instanceof CallExpressionShape)) {
576
+ return record
577
+ }
578
+ const override = shape.getArgument(0).ifObject()?.get('$override').ifObject()
579
+ if (!override) {
580
+ return record
581
+ }
582
+
583
+ const extra: { [key: string]: Shape } = {}
584
+ const overrideKeys = new Set<string>()
585
+ for (const [key, value] of override.entries()) {
586
+ if (key.startsWith('sys_')) {
587
+ diagnostics.warn(
588
+ value,
589
+ `$override: '${key}' starts with 'sys_' and will not be restored on transform — remove it or use a supported API property instead.`
590
+ )
591
+ continue
592
+ }
593
+ if (record.get(key).isDefined()) {
594
+ diagnostics.warn(
595
+ value,
596
+ `$override: '${key}' is already set by the plugin and will be ignored — use the corresponding API property instead.`
597
+ )
598
+ continue
599
+ }
600
+ overrideKeys.add(key)
601
+ extra[key] = value
602
+ }
603
+
604
+ const result = overrideKeys.size > 0 ? record.merge(extra) : record
605
+ return result.withOverrideKeys(overrideKeys)
606
+ }
607
+
608
+ /**
609
+ * Injects `$override` into the shape. Includes:
610
+ * - Custom fields (`u_*` / `x_*`) with non-empty values.
611
+ * - Any field that was explicitly in the existing record's `$override` — re-emitted with the
612
+ * fresh DB value.
613
+ */
614
+ function mergeOverrideFromRecord(record: Record, shape: Shape, accessed: Set<string>, existingRecord?: Record): Shape {
615
+ const inner = shape instanceof VariableStatementShape ? shape.getInitializer() : shape
616
+ const ce = inner instanceof CallExpressionShape ? inner : null
617
+ if (!ce) {
618
+ return shape
619
+ }
620
+
621
+ const firstArg = ce.getArgument(0).ifObject()
622
+ if (!firstArg || firstArg.get('$override').isDefined()) {
623
+ return shape
624
+ }
625
+
626
+ const existingOverrideKeys = existingRecord?.getOverrideKeys()
627
+ const override: globalThis.Record<string, Shape> = {}
628
+ for (const [key, value] of record.entries()) {
629
+ if (key.startsWith('sys_') || accessed.has(key)) {
630
+ continue
631
+ }
632
+ const isCustomField = key.startsWith('u_') || key.startsWith('x_')
633
+ if (!isCustomField && !existingOverrideKeys?.has(key)) {
634
+ continue
635
+ }
636
+ // Preserve empty strings when the key was explicitly put in $override previously —
637
+ // that's how users clear a field. Only skip empty strings for newly-discovered fields.
638
+ const wasExplicitlyOverridden = existingOverrideKeys?.has(key)
639
+ if (!value.isDefined() || (!wasExplicitlyOverridden && value.ifString()?.isEmpty())) {
640
+ continue
641
+ }
642
+ override[key] = value
643
+ }
644
+
645
+ if (Object.keys(override).length === 0) {
646
+ return shape
647
+ }
648
+
649
+ const modified = new CallExpressionShape({
650
+ source: ce.getSource(),
651
+ callee: ce.getCallee(),
652
+ args: [firstArg.merge({ $override: override }), ...ce.getArguments().slice(1)],
653
+ exportName: ce.getExportName(),
654
+ })
655
+
656
+ if (!(shape instanceof VariableStatementShape)) {
657
+ return modified
658
+ }
659
+ return new VariableStatementShape({
660
+ source: shape.getSource(),
661
+ variableName: shape.getVariableName(),
662
+ initializer: modified,
663
+ isExported: shape.isExported(),
664
+ })
665
+ }
666
+
667
+ type FileMatcher = (path: string, context: Context) => boolean | Promise<boolean>
668
+
669
+ type EntryPoints = {
670
+ readonly nodes: Map<SupportedKindName, Set<FileType | '*'>>
671
+ readonly files: {
672
+ readonly plugin: Plugin
673
+ readonly matcher: RegExp | FileMatcher
674
+ }[]
675
+ }
676
+
469
677
  export class Plugin {
470
- private readonly nodeToShapeCache = new Cache<ts.ts.Node, Result<Shape>>()
471
- private readonly shapeToSubclassCache = new Cache<Shape, Result<Shape>>()
472
- private readonly shapeToRecordCache = new Cache<Shape, Result<Record>>()
473
678
  private readonly relationships: SearchableRelationships = {}
474
679
  private readonly references: SearchableReferences = {}
475
680
 
@@ -630,23 +835,6 @@ export class Plugin {
630
835
  return this.config.name
631
836
  }
632
837
 
633
- /**
634
- * Get documentation metadata for APIs provided by this plugin.
635
- */
636
- getDocsMetadata(): { [apiName: string]: { tags: string[] } } | undefined {
637
- if (!this.config.docs) {
638
- return undefined
639
- }
640
-
641
- const manifest: { [apiName: string]: { tags: string[] } } = {}
642
-
643
- for (const doc of this.config.docs) {
644
- manifest[doc.apiName] = { tags: doc.tags }
645
- }
646
-
647
- return manifest
648
- }
649
-
650
838
  getDescendants(parent: Record, database: Database): Record[] {
651
839
  return this.traverseDescendants(parent, database)
652
840
  }
@@ -778,38 +966,6 @@ export class Plugin {
778
966
  return this.relationships
779
967
  }
780
968
 
781
- flushCache(): void {
782
- this.nodeToShapeCache.clear()
783
- this.shapeToRecordCache.clear()
784
- }
785
-
786
- async nodeToShape(node: SupportedNode, context: Omit<Context, 'self'>): Promise<Result<Shape>> {
787
- const entry = this.nodeToShapeCache.get(node.compilerNode)
788
- if (entry) {
789
- return entry.success ? entry.value : { success: false }
790
- }
791
-
792
- try {
793
- for (const config of this.config.nodes ?? []) {
794
- if (
795
- config.toShape &&
796
- getKindName(node) === config.node &&
797
- (!config.fileTypes ||
798
- config.fileTypes.includes(getFileType(node.getSourceFile().getFilePath(), context.project)))
799
- ) {
800
- const result = setCreator(this, await config.toShape.bind(this)(node, { ...context, self: this }))
801
- if (result.success) {
802
- return this.nodeToShapeCache.put(node.compilerNode, result)
803
- }
804
- }
805
- }
806
-
807
- return this.nodeToShapeCache.put(node.compilerNode, { success: false })
808
- } catch (e) {
809
- throw this.nodeToShapeCache.error(node.compilerNode, e)
810
- }
811
- }
812
-
813
969
  async commit(shape: Shape, target: ts.Node, context: Omit<CommitContext, 'self'>): Promise<CommitResult> {
814
970
  for (const { shape: shapeClass, commit } of this.config.shapes ?? []) {
815
971
  if (shape.is(shapeClass) && commit) {
@@ -820,7 +976,7 @@ export class Plugin {
820
976
  }
821
977
  }
822
978
 
823
- return { success: false }
979
+ return FAILURE
824
980
  }
825
981
 
826
982
  async getTarget(shape: Shape, context: Omit<Context, 'self'>): Promise<Result<ts.Node>> {
@@ -832,116 +988,70 @@ export class Plugin {
832
988
  }
833
989
  }
834
990
  }
835
-
836
- return { success: false }
837
- }
838
-
839
- async shapeToSubclass<const S extends Shape>(shape: S, context: Omit<Context, 'self'>): Promise<Result<S>> {
840
- const entry = this.shapeToSubclassCache.get(shape)
841
- if (entry) {
842
- return entry.success ? (entry.value as Result<S>) : { success: false }
843
- }
844
-
845
- try {
846
- for (const config of this.config.shapes ?? []) {
847
- if (
848
- config.toSubclass &&
849
- shape.constructor === config.shape &&
850
- (!config.fileTypes ||
851
- config.fileTypes.includes(getFileType(shape.getOriginalFilePath(), context.project)))
852
- ) {
853
- const result = setCreator(
854
- this,
855
- await config.toSubclass.bind(this)(shape, {
856
- ...context,
857
- self: this,
858
- })
859
- )
860
-
861
- if (result.success) {
862
- if (result.value.constructor === config.shape) {
863
- throw new Error(
864
- `Result of subclassing "${config.shape.name}" is an instance of the same class. The result MUST be a subclass.`
865
- )
866
- }
867
-
868
- return this.shapeToSubclassCache.put(shape, result as Result<S>)
869
- }
870
- }
871
- }
872
-
873
- return this.shapeToSubclassCache.put(shape, { success: false })
874
- } catch (e) {
875
- throw this.shapeToSubclassCache.error(shape, e)
876
- }
877
- }
878
-
879
- async shapeToRecord(shape: Shape, context: Omit<Context, 'self'>): Promise<Result<Record>> {
880
- const entry = this.shapeToRecordCache.get(shape)
881
- if (entry) {
882
- return entry.success ? entry.value : { success: false }
883
- }
884
-
885
- try {
886
- for (const config of this.config.shapes ?? []) {
887
- if (
888
- config.toRecord &&
889
- shape.is(config.shape) &&
890
- (!config.fileTypes ||
891
- config.fileTypes.includes(getFileType(shape.getOriginalFilePath(), context.project)))
892
- ) {
893
- const result = setCreator(this, await config.toRecord.bind(this)(shape, { ...context, self: this }))
894
- if (result.success) {
895
- return this.shapeToRecordCache.put(shape, result)
896
- }
897
- }
898
- }
899
-
900
- return this.shapeToRecordCache.put(shape, { success: false })
901
- } catch (e) {
902
- throw this.shapeToRecordCache.error(shape, e)
903
- }
991
+ return FAILURE
904
992
  }
905
993
 
906
- private async recordToShape0(record: Record, context: Omit<RecordContext, 'self'>): Promise<Result<Shape>> {
994
+ private async recordToShape0(
995
+ record: Record,
996
+ context: Omit<RecordContext, 'self'>,
997
+ existingRecord?: Record
998
+ ): Promise<Result<Shape>> {
907
999
  if (record.getAction() !== 'INSERT_OR_UPDATE') {
908
1000
  return { success: true, value: new DeletedShape({ source: record }) }
909
1001
  }
910
1002
 
911
1003
  const table = record.getTable()
912
- for (const [configTable, { toShape }] of Object.entries(this.config.records ?? {})) {
913
- if ((configTable === '*' || configTable === table) && toShape) {
914
- const result = setCreator(this, await toShape.bind(this)(record, { ...context, self: this }))
915
- if (result.success) {
916
- return result
917
- }
1004
+ for (const [configTable, recordConfig] of Object.entries(this.config.records ?? {})) {
1005
+ if (configTable !== '*' && configTable !== table) {
1006
+ continue
1007
+ }
1008
+ if (!recordConfig.toShape) {
1009
+ continue
1010
+ }
1011
+
1012
+ record.startTracking()
1013
+ const result = setCreator(this, await recordConfig.toShape.bind(this)(record, { ...context, self: this }))
1014
+ if (result.success) {
1015
+ const accessed = record.getTrackedFields()
1016
+ let value = mergeOverrideFromRecord(record, result.value, accessed, existingRecord)
1017
+ value = mergeProtectionPolicyFromRecord(record, value)
1018
+ value = mergeMetaFromRecord(record, value)
1019
+ return { ...result, value }
918
1020
  }
919
1021
  }
920
1022
 
921
- return { success: false }
1023
+ return FAILURE
922
1024
  }
923
1025
 
924
1026
  async recordToShape({
925
1027
  record,
926
1028
  database,
1029
+ existingDatabase,
927
1030
  context,
928
1031
  }: {
929
1032
  record: Record
930
1033
  database: Database
1034
+ existingDatabase?: Database
931
1035
  context: Omit<Context, 'self'>
932
1036
  }): Promise<Result<Shape>> {
933
1037
  context.logger.debug(`Transforming record into shape: ${record.getTable()}.${record.getId().getValue()}`)
934
1038
 
935
1039
  const descendants = this.getDescendants(record, database)
936
- return this.recordToShape0(record, {
937
- ...context,
938
- database,
939
- descendants: new Database(descendants.filter((r) => r.getAction() === 'INSERT_OR_UPDATE')),
940
- })
1040
+ const existingRecord = existingDatabase?.resolve(record.getId())
1041
+ return this.recordToShape0(
1042
+ record,
1043
+ {
1044
+ ...context,
1045
+ database,
1046
+ descendants: new Database(descendants.filter((r) => r.getAction() === 'INSERT_OR_UPDATE')),
1047
+ },
1048
+ existingRecord
1049
+ )
941
1050
  }
942
1051
 
943
1052
  async recordsToShapes({
944
1053
  database,
1054
+ existingDatabase,
945
1055
  creatorOnly,
946
1056
  changedOnly = false,
947
1057
  mode,
@@ -949,6 +1059,7 @@ export class Plugin {
949
1059
  context,
950
1060
  }: {
951
1061
  database: Database
1062
+ existingDatabase?: Database
952
1063
  creatorOnly: boolean
953
1064
  changedOnly?: boolean
954
1065
  mode: 'explicit' | 'catch-all'
@@ -1007,11 +1118,16 @@ export class Plugin {
1007
1118
  }
1008
1119
  }
1009
1120
 
1010
- const result = await this.recordToShape0(record, {
1011
- ...context,
1012
- database,
1013
- descendants: nonDeletedDescendants,
1014
- })
1121
+ const existingRecord = existingDatabase?.resolve(record.getId())
1122
+ const result = await this.recordToShape0(
1123
+ record,
1124
+ {
1125
+ ...context,
1126
+ database,
1127
+ descendants: nonDeletedDescendants,
1128
+ },
1129
+ existingRecord
1130
+ )
1015
1131
 
1016
1132
  if (result.success === 'partial') {
1017
1133
  for (const unhandledRecord of result.unhandledRecords) {
@@ -1041,7 +1157,7 @@ export class Plugin {
1041
1157
  } else if (
1042
1158
  !descendant.getCreator() ||
1043
1159
  !record.getCreator() ||
1044
- descendant.getCreator()!.getName() === record.getCreator()!.getName()
1160
+ descendant.getCreator()?.getName() === record.getCreator()?.getName()
1045
1161
  ) {
1046
1162
  // This descendant is handled by the same plugin that handled the root record
1047
1163
  handledRecords.insert(descendant)
@@ -1054,7 +1170,7 @@ export class Plugin {
1054
1170
 
1055
1171
  if (unhandledRecords.length > 0) {
1056
1172
  context.logger.debug(
1057
- `Plugin ${this.getName()} deferred handling of records: ${unhandledRecords.map((r) => r.getTable() + '_' + r.getId().getValue()).join(', ')}`
1173
+ `Plugin ${this.getName()} deferred handling of records: ${unhandledRecords.map((r) => `${r.getTable()}_${r.getId().getValue()}`).join(', ')}`
1058
1174
  )
1059
1175
  return { success: 'partial', value: shapes, unhandledRecords }
1060
1176
  }
@@ -1170,7 +1286,7 @@ export class Plugin {
1170
1286
  }
1171
1287
  }
1172
1288
 
1173
- return { success: false }
1289
+ return FAILURE
1174
1290
  }
1175
1291
 
1176
1292
  async recordsToFiles({
@@ -1229,38 +1345,44 @@ export class Plugin {
1229
1345
  return { success, value: files }
1230
1346
  }
1231
1347
 
1232
- async isEntryPoint(pathOrNode: string | SupportedNode, context: Omit<Context, 'self'>): Promise<boolean> {
1233
- if (ts.Node.isNode(pathOrNode)) {
1234
- for (const config of this.config.nodes ?? []) {
1235
- if (getKindName(pathOrNode) !== config.node) {
1236
- continue
1237
- }
1348
+ getEntryPoints(): EntryPoints {
1349
+ const entryPoints: EntryPoints = {
1350
+ nodes: new Map(),
1351
+ files: [],
1352
+ }
1238
1353
 
1239
- const fileType = getFileType(pathOrNode.getSourceFile().getFilePath(), context.project)
1240
- if (config.fileTypes && !config.fileTypes.includes(fileType)) {
1241
- continue
1242
- }
1354
+ for (const config of this.config.nodes ?? []) {
1355
+ if (!config.entryPoint) {
1356
+ continue
1357
+ }
1243
1358
 
1244
- return !!config.entryPoint
1359
+ const fileTypes = entryPoints.nodes.get(config.node) ?? new Set()
1360
+ if (config.fileTypes) {
1361
+ // Plugin only accepts the node from certain file types
1362
+ config.fileTypes.forEach((t) => fileTypes.add(t))
1363
+ } else {
1364
+ // Plugin accepts the node from any file type
1365
+ fileTypes.add('*')
1245
1366
  }
1246
- } else {
1247
- for (const config of this.config.files ?? []) {
1248
- if (config.matcher instanceof RegExp && !config.matcher.test(pathOrNode)) {
1249
- continue
1250
- }
1251
1367
 
1252
- if (
1253
- typeof config.matcher === 'function' &&
1254
- !(await config.matcher(pathOrNode, { ...context, self: this }))
1255
- ) {
1256
- continue
1257
- }
1368
+ entryPoints.nodes.set(config.node, fileTypes)
1369
+ }
1258
1370
 
1259
- return !!config.entryPoint
1371
+ for (const config of this.config.files ?? []) {
1372
+ if (!config.entryPoint) {
1373
+ continue
1260
1374
  }
1375
+
1376
+ if (!config.matcher) {
1377
+ throw new Error(
1378
+ `Plugin "${this.getName()}" defines a file entry point with no matcher. This would cause all files to be treated as entry points.`
1379
+ )
1380
+ }
1381
+
1382
+ entryPoints.files.push({ plugin: this, matcher: config.matcher })
1261
1383
  }
1262
1384
 
1263
- return false
1385
+ return entryPoints
1264
1386
  }
1265
1387
 
1266
1388
  async fileToRecord(file: File, context: Omit<Context, 'self'>): Promise<Result<Record>> {
@@ -1283,7 +1405,7 @@ export class Plugin {
1283
1405
  }
1284
1406
  }
1285
1407
 
1286
- return { success: false }
1408
+ return FAILURE
1287
1409
  }
1288
1410
 
1289
1411
  inspect(shape: Shape, context: Omit<Context, 'self'>): void {
@@ -1305,34 +1427,142 @@ export class Plugin {
1305
1427
  }
1306
1428
  }
1307
1429
 
1308
- return { success: false }
1430
+ return FAILURE
1431
+ }
1432
+
1433
+ getConfig(): PluginConfig {
1434
+ return this.config
1309
1435
  }
1310
1436
  }
1311
1437
 
1312
- export class Plugins {
1313
- constructor(private readonly plugins: Plugin[]) {}
1438
+ type PluginEntry = {
1439
+ plugin: Plugin
1440
+ shapeCount: number
1441
+ nodeToShapeCache: WeakMap<ts.ts.Node, Result<Shape>>
1442
+ shapeToSubclassCache: WeakMap<Shape, Result<Shape>>
1443
+ shapeToRecordCache: WeakMap<Shape, Result<Record>>
1444
+ }
1445
+
1446
+ export class Plugins implements Iterable<Plugin> {
1447
+ private plugins = new Map<string, PluginEntry>()
1448
+
1449
+ private nodeToShapeCache = new WeakMap<ts.ts.Node, Result<Shape>>()
1450
+ private shapeToSubclassCache = new WeakMap<Shape, Shape>()
1451
+ private shapeToRecordCache = new WeakMap<Shape, Result<Record>>()
1314
1452
 
1315
- toArray(): Plugin[] {
1316
- return this.plugins
1453
+ private readonly entryPoints: EntryPoints = {
1454
+ nodes: new Map(),
1455
+ files: [],
1317
1456
  }
1318
1457
 
1319
- remove(name: string): void {
1320
- const index = this.plugins.findIndex((plugin) => plugin.getName() === name)
1321
- if (index !== -1) {
1322
- this.plugins.splice(index, 1)
1458
+ constructor(plugins: Plugin[]) {
1459
+ for (const plugin of plugins) {
1460
+ this.plugins.set(plugin.getName(), Plugins.createEntry(plugin))
1461
+ }
1462
+
1463
+ this.reload()
1464
+ }
1465
+
1466
+ private static createEntry(plugin: Plugin): PluginEntry {
1467
+ return {
1468
+ plugin,
1469
+ shapeCount: 0,
1470
+ nodeToShapeCache: new WeakMap(),
1471
+ shapeToSubclassCache: new WeakMap(),
1472
+ shapeToRecordCache: new WeakMap(),
1473
+ }
1474
+ }
1475
+
1476
+ private get(plugin: string | Plugin): PluginEntry {
1477
+ const name = plugin instanceof Plugin ? plugin.getName() : plugin
1478
+ const entry = this.plugins.get(name)
1479
+ if (entry) {
1480
+ return entry
1481
+ }
1482
+
1483
+ if (plugin instanceof Plugin) {
1484
+ // Some plugins (mainly Flows) use "helper" plugins that are not actually registered. This is temporarily supporting that use case until we can get rid of those "helper" plugins.
1485
+ return Plugins.createEntry(plugin)
1486
+ }
1487
+
1488
+ throw new Error(`No such plugin with name "${name}"`)
1489
+ }
1490
+
1491
+ *[Symbol.iterator](): Iterator<Plugin> {
1492
+ for (const entry of this.plugins.values()) {
1493
+ yield entry.plugin
1494
+ }
1495
+ }
1496
+
1497
+ reload(): void {
1498
+ this.nodeToShapeCache = new WeakMap()
1499
+ this.shapeToSubclassCache = new WeakMap()
1500
+ this.shapeToRecordCache = new WeakMap()
1501
+
1502
+ this.entryPoints.nodes.clear()
1503
+ this.entryPoints.files.length = 0
1504
+
1505
+ for (const entry of this.plugins.values()) {
1506
+ entry.nodeToShapeCache = new WeakMap()
1507
+ entry.shapeToSubclassCache = new WeakMap()
1508
+ entry.shapeToRecordCache = new WeakMap()
1509
+ entry.shapeCount = 0
1510
+
1511
+ const { nodes, files } = entry.plugin.getEntryPoints()
1512
+ for (const [nodeKind, fileTypes] of nodes.entries()) {
1513
+ const existing = this.entryPoints.nodes.get(nodeKind) ?? new Set()
1514
+ for (const type of fileTypes) {
1515
+ existing.add(type)
1516
+ }
1517
+
1518
+ this.entryPoints.nodes.set(nodeKind, existing)
1519
+ }
1520
+
1521
+ this.entryPoints.files.push(...files)
1323
1522
  }
1324
1523
  }
1325
1524
 
1326
1525
  prepend(...plugins: Plugin[]): void {
1327
- this.plugins.unshift(...plugins)
1526
+ const newMap = new Map<string, PluginEntry>()
1527
+ for (const plugin of plugins) {
1528
+ const name = plugin.getName()
1529
+ newMap.set(name, Plugins.createEntry(plugin))
1530
+ this.plugins.delete(name)
1531
+ }
1532
+
1533
+ for (const [name, entry] of this.plugins.entries()) {
1534
+ newMap.set(name, entry)
1535
+ }
1536
+
1537
+ this.plugins = newMap
1538
+ this.reload()
1328
1539
  }
1329
1540
 
1330
1541
  append(...plugins: Plugin[]): void {
1331
- this.plugins.push(...plugins)
1542
+ for (const plugin of plugins) {
1543
+ const name = plugin.getName()
1544
+ this.plugins.delete(name)
1545
+ this.plugins.set(name, Plugins.createEntry(plugin))
1546
+ }
1547
+
1548
+ this.reload()
1549
+ }
1550
+
1551
+ getShapeCountPerPlugin() {
1552
+ return [...this.plugins.entries()].map(([name, { shapeCount }]) => ({
1553
+ plugin: name.replace(/Plugin$/, ''), //Report just the names 'ACL', 'Record', etc for existing metrics compatibility
1554
+ count: shapeCount,
1555
+ }))
1556
+ }
1557
+
1558
+ clearShapeCounts() {
1559
+ for (const entry of this.plugins.values()) {
1560
+ entry.shapeCount = 0
1561
+ }
1332
1562
  }
1333
1563
 
1334
1564
  getCoalesceStrategy(table: string): CoalesceStrategy | undefined {
1335
- for (const plugin of this.plugins) {
1565
+ for (const plugin of this) {
1336
1566
  const coalesceStrategy = plugin.getCoalesceStrategy(table)
1337
1567
  if (coalesceStrategy) {
1338
1568
  return coalesceStrategy
@@ -1343,7 +1573,7 @@ export class Plugins {
1343
1573
  }
1344
1574
 
1345
1575
  isComposite(table: string): boolean {
1346
- for (const plugin of this.plugins) {
1576
+ for (const plugin of this) {
1347
1577
  if (plugin.isComposite(table)) {
1348
1578
  return true
1349
1579
  }
@@ -1354,7 +1584,7 @@ export class Plugins {
1354
1584
 
1355
1585
  getCoalesceTables(): Set<string> {
1356
1586
  const tables = new Set<string>()
1357
- for (const plugin of this.plugins) {
1587
+ for (const plugin of this) {
1358
1588
  plugin.getCoalesceTables().forEach((t) => {
1359
1589
  tables.add(t)
1360
1590
  })
@@ -1365,7 +1595,7 @@ export class Plugins {
1365
1595
 
1366
1596
  getReferenceColumns(table: string): { [column: string]: string } {
1367
1597
  const refColumnEntries: [string, string][] = []
1368
- for (const plugin of this.plugins) {
1598
+ for (const plugin of this) {
1369
1599
  refColumnEntries.push(...Object.entries(plugin.getReferenceColumns(table)))
1370
1600
  }
1371
1601
 
@@ -1373,25 +1603,249 @@ export class Plugins {
1373
1603
  }
1374
1604
 
1375
1605
  async isEntryPoint(pathOrNode: string | SupportedNode, context: Omit<Context, 'self'>): Promise<boolean> {
1376
- for (const plugin of this.plugins) {
1377
- if (await plugin.isEntryPoint(pathOrNode, context)) {
1606
+ if (ts.Node.isNode(pathOrNode)) {
1607
+ const fileTypes = this.entryPoints.nodes.get(getKindName(pathOrNode))
1608
+ if (!fileTypes || fileTypes.size <= 0) {
1609
+ return false
1610
+ } else if (fileTypes.has('*')) {
1378
1611
  return true
1612
+ } else {
1613
+ return fileTypes.has(getFileType(pathOrNode.getSourceFile().getFilePath(), context.project))
1614
+ }
1615
+ } else {
1616
+ for (const { plugin, matcher } of this.entryPoints.files) {
1617
+ if (matcher instanceof RegExp && matcher.test(pathOrNode)) {
1618
+ return true
1619
+ } else if (typeof matcher === 'function' && (await matcher(pathOrNode, { ...context, self: plugin }))) {
1620
+ return true
1621
+ }
1379
1622
  }
1380
1623
  }
1381
1624
 
1382
1625
  return false
1383
1626
  }
1384
1627
 
1385
- getDocsMetadata(): { [apiName: string]: { tags: string[] } } {
1386
- return this.plugins.reduce(
1387
- (aggregated, plugin) => {
1388
- const pluginManifest = plugin.getDocsMetadata()
1389
- if (pluginManifest) {
1390
- Object.assign(aggregated, pluginManifest)
1628
+ async nodeToShape(
1629
+ node: SupportedNode,
1630
+ context: Omit<Context, 'self'>,
1631
+ ...plugins: (string | Plugin)[]
1632
+ ): Promise<Result<Shape>> {
1633
+ if (plugins.length > 0) {
1634
+ // Bypass global cache when requesting specific plugins
1635
+ return this.nodeToShape0(node, context, ...plugins)
1636
+ }
1637
+
1638
+ // We're not requesting specific plugins, so it's safe to use the global cache
1639
+ const cached = this.nodeToShapeCache.get(node.compilerNode)
1640
+ if (cached) {
1641
+ return cached
1642
+ }
1643
+
1644
+ try {
1645
+ const result = await this.nodeToShape0(node, context)
1646
+ this.nodeToShapeCache.set(node.compilerNode, result)
1647
+ return result
1648
+ } catch (e) {
1649
+ this.nodeToShapeCache.set(node.compilerNode, FAILURE)
1650
+ throw e
1651
+ }
1652
+ }
1653
+
1654
+ private async nodeToShape0(
1655
+ node: SupportedNode,
1656
+ context: Omit<Context, 'self'>,
1657
+ ...plugins: (string | Plugin)[]
1658
+ ): Promise<Result<Shape>> {
1659
+ for (const entry of plugins.length > 0 ? plugins.map((plugin) => this.get(plugin)) : this.plugins.values()) {
1660
+ const cached = entry.nodeToShapeCache.get(node.compilerNode)
1661
+ if (cached) {
1662
+ if (cached.success) {
1663
+ return cached
1664
+ } else {
1665
+ continue
1391
1666
  }
1392
- return aggregated
1393
- },
1394
- {} as { [apiName: string]: { tags: string[] } }
1395
- )
1667
+ }
1668
+
1669
+ for (const config of entry.plugin.getConfig().nodes ?? []) {
1670
+ if (
1671
+ config.toShape &&
1672
+ getKindName(node) === config.node &&
1673
+ (!config.fileTypes ||
1674
+ config.fileTypes.includes(getFileType(node.getSourceFile().getFilePath(), context.project)))
1675
+ ) {
1676
+ try {
1677
+ const result = setCreator(
1678
+ entry.plugin,
1679
+ await config.toShape.bind(entry.plugin)(node, { ...context, self: entry.plugin })
1680
+ )
1681
+
1682
+ entry.nodeToShapeCache.set(node.compilerNode, result)
1683
+ if (result.success) {
1684
+ entry.shapeCount++
1685
+ return result
1686
+ }
1687
+ } catch (error) {
1688
+ entry.nodeToShapeCache.set(node.compilerNode, FAILURE)
1689
+ context.logger.error(
1690
+ `Plugin "${entry.plugin.getName()}" failed to transform "${node.getKindName()}" node into shape:`,
1691
+ error
1692
+ )
1693
+ }
1694
+ }
1695
+ }
1696
+ }
1697
+
1698
+ return FAILURE
1699
+ }
1700
+
1701
+ async shapeToSubclass<const S extends Shape>(
1702
+ shape: S,
1703
+ context: Omit<Context, 'self'>,
1704
+ ...plugins: (string | Plugin)[]
1705
+ ): Promise<S> {
1706
+ if (plugins.length > 0) {
1707
+ // Bypass global cache when requesting specific plugins
1708
+ return this.shapeToSubclass0(shape, context, ...plugins)
1709
+ }
1710
+
1711
+ // We're not requesting specific plugins, so it's safe to use the global cache
1712
+ const cached = this.shapeToSubclassCache.get(shape)
1713
+ if (cached) {
1714
+ return cached as S
1715
+ }
1716
+
1717
+ const subclass = await this.shapeToSubclass0(shape, context)
1718
+ this.shapeToSubclassCache.set(shape, subclass)
1719
+ return subclass
1720
+ }
1721
+
1722
+ private async shapeToSubclass0<const S extends Shape>(
1723
+ shape: S,
1724
+ context: Omit<Context, 'self'>,
1725
+ ...plugins: (string | Plugin)[]
1726
+ ): Promise<S> {
1727
+ for (const entry of plugins.length > 0 ? plugins.map((plugin) => this.get(plugin)) : this.plugins.values()) {
1728
+ const cached = entry.shapeToSubclassCache.get(shape)
1729
+ if (cached) {
1730
+ if (cached.success) {
1731
+ return cached.value as S
1732
+ } else {
1733
+ continue
1734
+ }
1735
+ }
1736
+
1737
+ for (const config of entry.plugin.getConfig().shapes ?? []) {
1738
+ if (
1739
+ config.toSubclass &&
1740
+ shape.constructor === config.shape &&
1741
+ (!config.fileTypes ||
1742
+ config.fileTypes.includes(getFileType(shape.getOriginalFilePath(), context.project)))
1743
+ ) {
1744
+ try {
1745
+ const result = setCreator(
1746
+ entry.plugin,
1747
+ await config.toSubclass.bind(entry.plugin)(shape, { ...context, self: entry.plugin })
1748
+ ) as Result<S>
1749
+
1750
+ entry.shapeToSubclassCache.set(shape, result)
1751
+ if (result.success) {
1752
+ if (result.value.constructor === config.shape) {
1753
+ throw new Error(
1754
+ `Plugin "${entry.plugin.getName()}" tried to subclass "${config.shape.name}" to an instance of the same class. The result MUST be a subclass.`
1755
+ )
1756
+ }
1757
+
1758
+ entry.shapeCount++
1759
+ return await this.shapeToSubclass(result.value, context, ...plugins)
1760
+ }
1761
+ } catch (error) {
1762
+ entry.shapeToSubclassCache.set(shape, FAILURE)
1763
+ context.logger.error(
1764
+ `Plugin "${entry.plugin.getName()}" failed to subclass "${shape.getKind()}" shape:`,
1765
+ error
1766
+ )
1767
+ }
1768
+ }
1769
+ }
1770
+ }
1771
+
1772
+ return shape
1773
+ }
1774
+
1775
+ async shapeToRecord(
1776
+ shape: Shape,
1777
+ context: Omit<Context, 'self'>,
1778
+ ...plugins: (string | Plugin)[]
1779
+ ): Promise<Result<Record>> {
1780
+ if (shape.isRecord()) {
1781
+ return { success: true, value: shape }
1782
+ }
1783
+
1784
+ if (plugins.length > 0) {
1785
+ // Bypass global cache when requesting specific plugins
1786
+ return this.shapeToRecord0(shape, context, ...plugins)
1787
+ }
1788
+
1789
+ // We're not requesting specific plugins, so it's safe to use the global cache
1790
+ const cached = this.shapeToRecordCache.get(shape)
1791
+ if (cached) {
1792
+ return cached
1793
+ }
1794
+
1795
+ const result = await this.shapeToRecord0(shape, context)
1796
+ this.shapeToRecordCache.set(shape, result)
1797
+ return result
1798
+ }
1799
+
1800
+ private async shapeToRecord0(
1801
+ shape: Shape,
1802
+ context: Omit<Context, 'self'>,
1803
+ ...plugins: (string | Plugin)[]
1804
+ ): Promise<Result<Record>> {
1805
+ for (const entry of plugins.length > 0 ? plugins.map((plugin) => this.get(plugin)) : this.plugins.values()) {
1806
+ const cached = entry.shapeToRecordCache.get(shape)
1807
+ if (cached) {
1808
+ if (cached.success) {
1809
+ return cached
1810
+ } else {
1811
+ continue
1812
+ }
1813
+ }
1814
+
1815
+ for (const config of entry.plugin.getConfig().shapes ?? []) {
1816
+ if (
1817
+ config.toRecord &&
1818
+ shape.is(config.shape) &&
1819
+ (!config.fileTypes ||
1820
+ config.fileTypes.includes(getFileType(shape.getOriginalFilePath(), context.project)))
1821
+ ) {
1822
+ try {
1823
+ const result = setCreator(
1824
+ entry.plugin,
1825
+ await config.toRecord.bind(entry.plugin)(shape, { ...context, self: entry.plugin })
1826
+ )
1827
+
1828
+ entry.shapeToRecordCache.set(shape, result)
1829
+ if (result.success) {
1830
+ entry.shapeCount++
1831
+ result.value = mergeOverrideFromShape(
1832
+ shape,
1833
+ mergeSysPolicyFromShape(shape, result.value),
1834
+ context.diagnostics
1835
+ )
1836
+ return result
1837
+ }
1838
+ } catch (error) {
1839
+ entry.shapeToRecordCache.set(shape, FAILURE)
1840
+ context.logger.error(
1841
+ `Plugin "${entry.plugin.getName()}" failed to transform "${shape.getKind()}" shape into record:`,
1842
+ error
1843
+ )
1844
+ }
1845
+ }
1846
+ }
1847
+ }
1848
+
1849
+ return FAILURE
1396
1850
  }
1397
1851
  }