@hyperfrontend/versioning 0.1.0 → 0.3.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.
Files changed (167) hide show
  1. package/ARCHITECTURE.md +50 -1
  2. package/CHANGELOG.md +37 -23
  3. package/README.md +19 -14
  4. package/changelog/index.cjs.js +38 -6
  5. package/changelog/index.cjs.js.map +1 -1
  6. package/changelog/index.esm.js +38 -6
  7. package/changelog/index.esm.js.map +1 -1
  8. package/changelog/models/entry.d.ts +5 -0
  9. package/changelog/models/entry.d.ts.map +1 -1
  10. package/changelog/models/index.cjs.js +2 -0
  11. package/changelog/models/index.cjs.js.map +1 -1
  12. package/changelog/models/index.esm.js +2 -0
  13. package/changelog/models/index.esm.js.map +1 -1
  14. package/changelog/operations/index.cjs.js.map +1 -1
  15. package/changelog/operations/index.esm.js.map +1 -1
  16. package/changelog/parse/index.cjs.js +85 -6
  17. package/changelog/parse/index.cjs.js.map +1 -1
  18. package/changelog/parse/index.esm.js +85 -6
  19. package/changelog/parse/index.esm.js.map +1 -1
  20. package/changelog/parse/line.d.ts.map +1 -1
  21. package/changelog/parse/parser.d.ts +0 -6
  22. package/changelog/parse/parser.d.ts.map +1 -1
  23. package/commits/classify/classifier.d.ts +73 -0
  24. package/commits/classify/classifier.d.ts.map +1 -0
  25. package/commits/classify/index.cjs.js +707 -0
  26. package/commits/classify/index.cjs.js.map +1 -0
  27. package/commits/classify/index.d.ts +8 -0
  28. package/commits/classify/index.d.ts.map +1 -0
  29. package/commits/classify/index.esm.js +679 -0
  30. package/commits/classify/index.esm.js.map +1 -0
  31. package/commits/classify/infrastructure.d.ts +205 -0
  32. package/commits/classify/infrastructure.d.ts.map +1 -0
  33. package/commits/classify/models.d.ts +108 -0
  34. package/commits/classify/models.d.ts.map +1 -0
  35. package/commits/classify/project-scopes.d.ts +69 -0
  36. package/commits/classify/project-scopes.d.ts.map +1 -0
  37. package/commits/index.cjs.js +704 -0
  38. package/commits/index.cjs.js.map +1 -1
  39. package/commits/index.d.ts +1 -0
  40. package/commits/index.d.ts.map +1 -1
  41. package/commits/index.esm.js +678 -1
  42. package/commits/index.esm.js.map +1 -1
  43. package/flow/executor/execute.d.ts +6 -0
  44. package/flow/executor/execute.d.ts.map +1 -1
  45. package/flow/executor/index.cjs.js +1617 -43
  46. package/flow/executor/index.cjs.js.map +1 -1
  47. package/flow/executor/index.esm.js +1623 -49
  48. package/flow/executor/index.esm.js.map +1 -1
  49. package/flow/index.cjs.js +6749 -2938
  50. package/flow/index.cjs.js.map +1 -1
  51. package/flow/index.esm.js +6751 -2944
  52. package/flow/index.esm.js.map +1 -1
  53. package/flow/models/index.cjs.js +138 -0
  54. package/flow/models/index.cjs.js.map +1 -1
  55. package/flow/models/index.d.ts +1 -1
  56. package/flow/models/index.d.ts.map +1 -1
  57. package/flow/models/index.esm.js +138 -1
  58. package/flow/models/index.esm.js.map +1 -1
  59. package/flow/models/types.d.ts +180 -3
  60. package/flow/models/types.d.ts.map +1 -1
  61. package/flow/presets/conventional.d.ts +9 -8
  62. package/flow/presets/conventional.d.ts.map +1 -1
  63. package/flow/presets/independent.d.ts.map +1 -1
  64. package/flow/presets/index.cjs.js +3641 -303
  65. package/flow/presets/index.cjs.js.map +1 -1
  66. package/flow/presets/index.esm.js +3641 -303
  67. package/flow/presets/index.esm.js.map +1 -1
  68. package/flow/presets/synced.d.ts.map +1 -1
  69. package/flow/steps/analyze-commits.d.ts +9 -6
  70. package/flow/steps/analyze-commits.d.ts.map +1 -1
  71. package/flow/steps/calculate-bump.d.ts.map +1 -1
  72. package/flow/steps/fetch-registry.d.ts.map +1 -1
  73. package/flow/steps/generate-changelog.d.ts +5 -0
  74. package/flow/steps/generate-changelog.d.ts.map +1 -1
  75. package/flow/steps/index.cjs.js +3663 -328
  76. package/flow/steps/index.cjs.js.map +1 -1
  77. package/flow/steps/index.d.ts +2 -1
  78. package/flow/steps/index.d.ts.map +1 -1
  79. package/flow/steps/index.esm.js +3661 -329
  80. package/flow/steps/index.esm.js.map +1 -1
  81. package/flow/steps/resolve-repository.d.ts +36 -0
  82. package/flow/steps/resolve-repository.d.ts.map +1 -0
  83. package/flow/steps/update-packages.d.ts.map +1 -1
  84. package/git/factory.d.ts +14 -0
  85. package/git/factory.d.ts.map +1 -1
  86. package/git/index.cjs.js +65 -0
  87. package/git/index.cjs.js.map +1 -1
  88. package/git/index.esm.js +66 -2
  89. package/git/index.esm.js.map +1 -1
  90. package/git/operations/index.cjs.js +40 -0
  91. package/git/operations/index.cjs.js.map +1 -1
  92. package/git/operations/index.d.ts +1 -1
  93. package/git/operations/index.d.ts.map +1 -1
  94. package/git/operations/index.esm.js +41 -2
  95. package/git/operations/index.esm.js.map +1 -1
  96. package/git/operations/log.d.ts +23 -0
  97. package/git/operations/log.d.ts.map +1 -1
  98. package/index.cjs.js +7547 -4947
  99. package/index.cjs.js.map +1 -1
  100. package/index.d.ts +3 -1
  101. package/index.d.ts.map +1 -1
  102. package/index.esm.js +7550 -4954
  103. package/index.esm.js.map +1 -1
  104. package/package.json +39 -1
  105. package/registry/index.cjs.js +3 -3
  106. package/registry/index.cjs.js.map +1 -1
  107. package/registry/index.esm.js +3 -3
  108. package/registry/index.esm.js.map +1 -1
  109. package/registry/models/index.cjs.js +2 -0
  110. package/registry/models/index.cjs.js.map +1 -1
  111. package/registry/models/index.esm.js +2 -0
  112. package/registry/models/index.esm.js.map +1 -1
  113. package/registry/models/version-info.d.ts +10 -0
  114. package/registry/models/version-info.d.ts.map +1 -1
  115. package/registry/npm/client.d.ts.map +1 -1
  116. package/registry/npm/index.cjs.js +1 -3
  117. package/registry/npm/index.cjs.js.map +1 -1
  118. package/registry/npm/index.esm.js +1 -3
  119. package/registry/npm/index.esm.js.map +1 -1
  120. package/repository/index.cjs.js +998 -0
  121. package/repository/index.cjs.js.map +1 -0
  122. package/repository/index.d.ts +4 -0
  123. package/repository/index.d.ts.map +1 -0
  124. package/repository/index.esm.js +981 -0
  125. package/repository/index.esm.js.map +1 -0
  126. package/repository/models/index.cjs.js +301 -0
  127. package/repository/models/index.cjs.js.map +1 -0
  128. package/repository/models/index.d.ts +7 -0
  129. package/repository/models/index.d.ts.map +1 -0
  130. package/repository/models/index.esm.js +290 -0
  131. package/repository/models/index.esm.js.map +1 -0
  132. package/repository/models/platform.d.ts +58 -0
  133. package/repository/models/platform.d.ts.map +1 -0
  134. package/repository/models/repository-config.d.ts +132 -0
  135. package/repository/models/repository-config.d.ts.map +1 -0
  136. package/repository/models/resolution.d.ts +121 -0
  137. package/repository/models/resolution.d.ts.map +1 -0
  138. package/repository/parse/index.cjs.js +755 -0
  139. package/repository/parse/index.cjs.js.map +1 -0
  140. package/repository/parse/index.d.ts +5 -0
  141. package/repository/parse/index.d.ts.map +1 -0
  142. package/repository/parse/index.esm.js +749 -0
  143. package/repository/parse/index.esm.js.map +1 -0
  144. package/repository/parse/package-json.d.ts +100 -0
  145. package/repository/parse/package-json.d.ts.map +1 -0
  146. package/repository/parse/url.d.ts +81 -0
  147. package/repository/parse/url.d.ts.map +1 -0
  148. package/repository/url/compare.d.ts +84 -0
  149. package/repository/url/compare.d.ts.map +1 -0
  150. package/repository/url/index.cjs.js +178 -0
  151. package/repository/url/index.cjs.js.map +1 -0
  152. package/repository/url/index.d.ts +3 -0
  153. package/repository/url/index.d.ts.map +1 -0
  154. package/repository/url/index.esm.js +176 -0
  155. package/repository/url/index.esm.js.map +1 -0
  156. package/workspace/discovery/changelog-path.d.ts +3 -7
  157. package/workspace/discovery/changelog-path.d.ts.map +1 -1
  158. package/workspace/discovery/index.cjs.js +408 -335
  159. package/workspace/discovery/index.cjs.js.map +1 -1
  160. package/workspace/discovery/index.esm.js +408 -335
  161. package/workspace/discovery/index.esm.js.map +1 -1
  162. package/workspace/discovery/packages.d.ts +0 -6
  163. package/workspace/discovery/packages.d.ts.map +1 -1
  164. package/workspace/index.cjs.js +84 -11
  165. package/workspace/index.cjs.js.map +1 -1
  166. package/workspace/index.esm.js +84 -11
  167. package/workspace/index.esm.js.map +1 -1
@@ -117,6 +117,10 @@ const keys = _Object.keys;
117
117
  * (Safe copy) Returns an array of key/values of the enumerable own properties of an object.
118
118
  */
119
119
  const entries = _Object.entries;
120
+ /**
121
+ * (Safe copy) Returns an array of values of the enumerable own properties of an object.
122
+ */
123
+ const values = _Object.values;
120
124
  /**
121
125
  * (Safe copy) Adds one or more properties to an object, and/or modifies attributes of existing properties.
122
126
  */
@@ -574,7 +578,7 @@ function createScopedLogger(namespace, options = {}) {
574
578
  */
575
579
  createScopedLogger('project-scope');
576
580
 
577
- createScopedLogger('project-scope:fs');
581
+ const fsLogger = createScopedLogger('project-scope:fs');
578
582
  /**
579
583
  * Create a file system error with code and context.
580
584
  *
@@ -591,6 +595,71 @@ function createFileSystemError(message, code, context) {
591
595
  });
592
596
  return error;
593
597
  }
598
+ /**
599
+ * Read file contents as string.
600
+ *
601
+ * @param filePath - Path to file
602
+ * @param encoding - File encoding (default: utf-8)
603
+ * @returns File contents as string
604
+ * @throws {Error} If file doesn't exist or can't be read
605
+ *
606
+ * @example
607
+ * ```typescript
608
+ * import { readFileContent } from '@hyperfrontend/project-scope'
609
+ *
610
+ * const content = readFileContent('./package.json')
611
+ * console.log(content) // JSON string
612
+ * ```
613
+ */
614
+ function readFileContent(filePath, encoding = 'utf-8') {
615
+ if (!node_fs.existsSync(filePath)) {
616
+ fsLogger.debug('File not found', { path: filePath });
617
+ throw createFileSystemError(`File not found: ${filePath}`, 'FS_NOT_FOUND', { path: filePath, operation: 'read' });
618
+ }
619
+ try {
620
+ return node_fs.readFileSync(filePath, { encoding });
621
+ }
622
+ catch (error) {
623
+ fsLogger.warn('Failed to read file', { path: filePath });
624
+ throw createFileSystemError(`Failed to read file: ${filePath}`, 'FS_READ_ERROR', { path: filePath, operation: 'read', cause: error });
625
+ }
626
+ }
627
+ /**
628
+ * Read file if exists, return null otherwise.
629
+ *
630
+ * @param filePath - Path to file
631
+ * @param encoding - File encoding (default: utf-8)
632
+ * @returns File contents or null if file doesn't exist
633
+ */
634
+ function readFileIfExists(filePath, encoding = 'utf-8') {
635
+ if (!node_fs.existsSync(filePath)) {
636
+ return null;
637
+ }
638
+ try {
639
+ return node_fs.readFileSync(filePath, { encoding });
640
+ }
641
+ catch {
642
+ return null;
643
+ }
644
+ }
645
+ /**
646
+ * Read and parse JSON file if exists, return null otherwise.
647
+ *
648
+ * @param filePath - Path to JSON file
649
+ * @returns Parsed JSON object or null if file doesn't exist or is invalid
650
+ */
651
+ function readJsonFileIfExists(filePath) {
652
+ if (!node_fs.existsSync(filePath)) {
653
+ return null;
654
+ }
655
+ try {
656
+ const content = node_fs.readFileSync(filePath, { encoding: 'utf-8' });
657
+ return parse(content);
658
+ }
659
+ catch {
660
+ return null;
661
+ }
662
+ }
594
663
 
595
664
  const fsWriteLogger = createScopedLogger('project-scope:fs:write');
596
665
  /**
@@ -712,6 +781,17 @@ function readDirectory(dirPath) {
712
781
  }
713
782
  }
714
783
 
784
+ /**
785
+ * Join path segments.
786
+ * Uses platform-specific separators (e.g., / or \).
787
+ *
788
+ * @param paths - Path segments to join
789
+ * @returns Joined path
790
+ */
791
+ function join(...paths) {
792
+ return node_path.join(...paths);
793
+ }
794
+
715
795
  /**
716
796
  * Normalize path separators to forward slashes.
717
797
  *
@@ -787,9 +867,223 @@ function getDirname(filePath) {
787
867
  return normalizePath(node_path.dirname(filePath));
788
868
  }
789
869
 
790
- createScopedLogger('project-scope:fs:traversal');
870
+ const fsTraversalLogger = createScopedLogger('project-scope:fs:traversal');
871
+ /**
872
+ * Generic upward directory traversal.
873
+ * Name avoids similarity to fs.readdir/fs.readdirSync.
874
+ *
875
+ * @param startPath - Starting directory
876
+ * @param predicate - Function to test each directory
877
+ * @returns First matching directory or null
878
+ */
879
+ function traverseUpward(startPath, predicate) {
880
+ fsTraversalLogger.debug('Starting upward traversal', { startPath });
881
+ let currentPath = node_path.resolve(startPath);
882
+ const rootPath = node_path.parse(currentPath).root;
883
+ while (currentPath !== rootPath) {
884
+ if (predicate(currentPath)) {
885
+ fsTraversalLogger.debug('Upward traversal found match', { startPath, foundPath: currentPath });
886
+ return currentPath;
887
+ }
888
+ currentPath = node_path.dirname(currentPath);
889
+ }
890
+ // Check root directory
891
+ if (predicate(rootPath)) {
892
+ fsTraversalLogger.debug('Upward traversal found match at root', { startPath, foundPath: rootPath });
893
+ return rootPath;
894
+ }
895
+ fsTraversalLogger.debug('Upward traversal found no match', { startPath });
896
+ return null;
897
+ }
898
+ /**
899
+ * Find directory containing any of the specified marker files.
900
+ *
901
+ * @param startPath - Starting directory
902
+ * @param markers - Array of marker file names to search for
903
+ * @returns First directory containing any marker, or null
904
+ */
905
+ function locateByMarkers(startPath, markers) {
906
+ fsTraversalLogger.debug('Locating by markers', { startPath, markers });
907
+ const result = traverseUpward(startPath, (dir) => markers.some((marker) => exists(join(dir, marker))));
908
+ if (result) {
909
+ fsTraversalLogger.debug('Found directory with marker', { startPath, foundPath: result });
910
+ }
911
+ return result;
912
+ }
913
+ /**
914
+ * Find directory where predicate returns true, starting from given path.
915
+ *
916
+ * @param startPath - Starting directory
917
+ * @param test - Function to test if directory matches criteria
918
+ * @returns Matching directory path or null
919
+ */
920
+ function findUpwardWhere(startPath, test) {
921
+ fsTraversalLogger.debug('Finding upward where condition met', { startPath });
922
+ return traverseUpward(startPath, test);
923
+ }
791
924
 
792
- createScopedLogger('project-scope:project:package');
925
+ /**
926
+ * Create a structured error with code and optional context.
927
+ *
928
+ * @param message - The human-readable error message
929
+ * @param code - The machine-readable error code for programmatic handling
930
+ * @param context - Additional contextual information about the error
931
+ * @returns Structured error instance with code and context properties
932
+ *
933
+ * @example
934
+ * ```typescript
935
+ * import { createStructuredError } from '@hyperfrontend/project-scope'
936
+ *
937
+ * throw createStructuredError(
938
+ * 'Configuration file not found',
939
+ * 'CONFIG_NOT_FOUND',
940
+ * { path: './config.json', searched: ['./config.json', './settings.json'] }
941
+ * )
942
+ * ```
943
+ */
944
+ function createStructuredError(message, code, context) {
945
+ const error = createError(message);
946
+ error.code = code;
947
+ error.context = context ?? {};
948
+ return error;
949
+ }
950
+ /**
951
+ * Create a configuration-related error.
952
+ *
953
+ * @param message - The human-readable error message
954
+ * @param code - The machine-readable error code for programmatic handling
955
+ * @param context - Additional contextual information (e.g., file path, config key)
956
+ * @returns Structured error instance tagged with type 'config'
957
+ */
958
+ function createConfigError(message, code, context) {
959
+ return createStructuredError(message, code, { ...context, type: 'config' });
960
+ }
961
+
962
+ const packageLogger = createScopedLogger('project-scope:project:package');
963
+ /**
964
+ * Verifies that a value is an object with only string values,
965
+ * used for validating dependency maps and script definitions.
966
+ *
967
+ * @param value - Value to check
968
+ * @returns True if value is a record of strings
969
+ */
970
+ function isStringRecord(value) {
971
+ if (typeof value !== 'object' || value === null)
972
+ return false;
973
+ return values(value).every((v) => typeof v === 'string');
974
+ }
975
+ /**
976
+ * Extracts and normalizes the workspaces field from package.json,
977
+ * supporting both array format and object with packages array.
978
+ *
979
+ * @param value - Raw workspaces value from package.json
980
+ * @returns Normalized workspace patterns or undefined if invalid
981
+ */
982
+ function parseWorkspaces(value) {
983
+ if (isArray(value) && value.every((v) => typeof v === 'string')) {
984
+ return value;
985
+ }
986
+ if (typeof value === 'object' && value !== null) {
987
+ const obj = value;
988
+ if (isArray(obj['packages'])) {
989
+ return { packages: obj['packages'] };
990
+ }
991
+ }
992
+ return undefined;
993
+ }
994
+ /**
995
+ * Validate and normalize package.json data.
996
+ *
997
+ * @param data - Raw parsed data
998
+ * @returns Validated package.json
999
+ */
1000
+ function validatePackageJson(data) {
1001
+ if (typeof data !== 'object' || data === null) {
1002
+ throw createError('package.json must be an object');
1003
+ }
1004
+ const pkg = data;
1005
+ return {
1006
+ name: typeof pkg['name'] === 'string' ? pkg['name'] : undefined,
1007
+ version: typeof pkg['version'] === 'string' ? pkg['version'] : undefined,
1008
+ description: typeof pkg['description'] === 'string' ? pkg['description'] : undefined,
1009
+ main: typeof pkg['main'] === 'string' ? pkg['main'] : undefined,
1010
+ module: typeof pkg['module'] === 'string' ? pkg['module'] : undefined,
1011
+ browser: typeof pkg['browser'] === 'string' ? pkg['browser'] : undefined,
1012
+ types: typeof pkg['types'] === 'string' ? pkg['types'] : undefined,
1013
+ bin: typeof pkg['bin'] === 'string' || isStringRecord(pkg['bin']) ? pkg['bin'] : undefined,
1014
+ scripts: isStringRecord(pkg['scripts']) ? pkg['scripts'] : undefined,
1015
+ dependencies: isStringRecord(pkg['dependencies']) ? pkg['dependencies'] : undefined,
1016
+ devDependencies: isStringRecord(pkg['devDependencies']) ? pkg['devDependencies'] : undefined,
1017
+ peerDependencies: isStringRecord(pkg['peerDependencies']) ? pkg['peerDependencies'] : undefined,
1018
+ optionalDependencies: isStringRecord(pkg['optionalDependencies']) ? pkg['optionalDependencies'] : undefined,
1019
+ workspaces: parseWorkspaces(pkg['workspaces']),
1020
+ exports: typeof pkg['exports'] === 'object' ? pkg['exports'] : undefined,
1021
+ engines: isStringRecord(pkg['engines']) ? pkg['engines'] : undefined,
1022
+ ...pkg,
1023
+ };
1024
+ }
1025
+ /**
1026
+ * Reads and parses package.json from a directory, validating
1027
+ * the structure and normalizing fields to the PackageJson interface.
1028
+ *
1029
+ * @param projectPath - Project directory path or path to package.json
1030
+ * @returns Parsed package.json
1031
+ * @throws {Error} Error if file doesn't exist or is invalid
1032
+ */
1033
+ function readPackageJson(projectPath) {
1034
+ const packageJsonPath = projectPath.endsWith('package.json') ? projectPath : node_path.join(projectPath, 'package.json');
1035
+ packageLogger.debug('Reading package.json', { path: packageJsonPath });
1036
+ const content = readFileContent(packageJsonPath);
1037
+ try {
1038
+ const data = parse(content);
1039
+ const validated = validatePackageJson(data);
1040
+ packageLogger.debug('Package.json read successfully', { path: packageJsonPath, name: validated.name });
1041
+ return validated;
1042
+ }
1043
+ catch (error) {
1044
+ packageLogger.warn('Failed to parse package.json', {
1045
+ path: packageJsonPath,
1046
+ error: error instanceof Error ? error.message : String(error),
1047
+ });
1048
+ throw createConfigError(`Failed to parse package.json: ${packageJsonPath}`, 'CONFIG_PARSE_ERROR', {
1049
+ filePath: packageJsonPath,
1050
+ cause: error,
1051
+ });
1052
+ }
1053
+ }
1054
+ /**
1055
+ * Attempts to read and parse package.json if it exists,
1056
+ * returning null on missing file or parse failure.
1057
+ *
1058
+ * @param projectPath - Project directory path or path to package.json
1059
+ * @returns Parsed package.json or null if not found
1060
+ */
1061
+ function readPackageJsonIfExists(projectPath) {
1062
+ const packageJsonPath = projectPath.endsWith('package.json') ? projectPath : node_path.join(projectPath, 'package.json');
1063
+ const content = readFileIfExists(packageJsonPath);
1064
+ if (!content) {
1065
+ packageLogger.debug('Package.json not found', { path: packageJsonPath });
1066
+ return null;
1067
+ }
1068
+ try {
1069
+ const validated = validatePackageJson(parse(content));
1070
+ packageLogger.debug('Package.json loaded', { path: packageJsonPath, name: validated.name });
1071
+ return validated;
1072
+ }
1073
+ catch {
1074
+ packageLogger.debug('Failed to parse package.json, returning null', { path: packageJsonPath });
1075
+ return null;
1076
+ }
1077
+ }
1078
+ /**
1079
+ * Find nearest package.json by walking up the directory tree.
1080
+ *
1081
+ * @param startPath - Starting path
1082
+ * @returns Path to directory containing package.json, or null if not found
1083
+ */
1084
+ function findNearestPackageJson(startPath) {
1085
+ return locateByMarkers(startPath, ['package.json']);
1086
+ }
793
1087
 
794
1088
  createScopedLogger('project-scope:heuristics:deps');
795
1089
 
@@ -925,9 +1219,410 @@ function createCache$1(options) {
925
1219
  return freeze(cache);
926
1220
  }
927
1221
 
928
- createScopedLogger('project-scope:project:walk');
1222
+ /**
1223
+ * Pattern matching utilities with ReDoS protection.
1224
+ * Uses character-by-character matching instead of regex where possible.
1225
+ */
1226
+ /**
1227
+ * Match path against glob pattern using safe character iteration.
1228
+ * Avoids regex to prevent ReDoS attacks.
1229
+ *
1230
+ * Supported patterns:
1231
+ * - * matches any characters except /
1232
+ * - ** matches any characters including /
1233
+ * - ? matches exactly one character except /
1234
+ * - {a,b,c} matches any of the alternatives
1235
+ *
1236
+ * @param path - The filesystem path to test against the pattern
1237
+ * @param pattern - The glob pattern to match against
1238
+ * @returns True if path matches pattern
1239
+ *
1240
+ * @example
1241
+ * ```typescript
1242
+ * import { matchGlobPattern } from '@hyperfrontend/project-scope'
1243
+ *
1244
+ * matchGlobPattern('src/utils/helper.ts', '\*\*\/*.ts') // true
1245
+ * matchGlobPattern('test.spec.ts', '\*.spec.ts') // true
1246
+ * matchGlobPattern('config.json', '\*.{json,yaml}') // true
1247
+ * matchGlobPattern('src/index.ts', 'src/\*.ts') // true
1248
+ * ```
1249
+ */
1250
+ function matchGlobPattern(path, pattern) {
1251
+ return matchSegments(path.split('/'), pattern.split('/'), 0, 0);
1252
+ }
1253
+ /**
1254
+ * Internal recursive function to match path segments against pattern segments.
1255
+ *
1256
+ * @param pathParts - Array of path segments split by '/'
1257
+ * @param patternParts - Array of pattern segments split by '/'
1258
+ * @param pathIdx - Current index in pathParts being examined
1259
+ * @param patternIdx - Current index in patternParts being examined
1260
+ * @returns True if remaining segments match
1261
+ */
1262
+ function matchSegments(pathParts, patternParts, pathIdx, patternIdx) {
1263
+ // Base cases
1264
+ if (pathIdx === pathParts.length && patternIdx === patternParts.length) {
1265
+ return true; // Both exhausted = match
1266
+ }
1267
+ if (patternIdx >= patternParts.length) {
1268
+ return false; // Pattern exhausted but path remains
1269
+ }
1270
+ const patternPart = patternParts[patternIdx];
1271
+ // Handle ** (globstar) - matches zero or more directories
1272
+ if (patternPart === '**') {
1273
+ // Try matching rest of pattern against current position and all future positions
1274
+ for (let i = pathIdx; i <= pathParts.length; i++) {
1275
+ if (matchSegments(pathParts, patternParts, i, patternIdx + 1)) {
1276
+ return true;
1277
+ }
1278
+ }
1279
+ return false;
1280
+ }
1281
+ if (pathIdx >= pathParts.length) {
1282
+ return false; // Path exhausted but pattern remains (and it's not **)
1283
+ }
1284
+ const pathPart = pathParts[pathIdx];
1285
+ // Match current segment
1286
+ if (matchSegment(pathPart, patternPart)) {
1287
+ return matchSegments(pathParts, patternParts, pathIdx + 1, patternIdx + 1);
1288
+ }
1289
+ return false;
1290
+ }
1291
+ /**
1292
+ * Match a single path segment against a pattern segment.
1293
+ * Handles *, ?, and {a,b,c} patterns.
1294
+ *
1295
+ * @param text - The path segment text to match
1296
+ * @param pattern - The pattern segment to match against
1297
+ * @returns True if the text matches the pattern
1298
+ */
1299
+ function matchSegment(text, pattern) {
1300
+ let textIdx = 0;
1301
+ let patternIdx = 0;
1302
+ while (patternIdx < pattern.length) {
1303
+ const char = pattern[patternIdx];
1304
+ if (char === '*') {
1305
+ // * matches zero or more characters
1306
+ patternIdx++;
1307
+ if (patternIdx === pattern.length) {
1308
+ return true; // * at end matches rest of string
1309
+ }
1310
+ // Try matching rest of pattern at each position in text
1311
+ for (let i = textIdx; i <= text.length; i++) {
1312
+ if (matchSegmentFrom(text, i, pattern, patternIdx)) {
1313
+ return true;
1314
+ }
1315
+ }
1316
+ return false;
1317
+ }
1318
+ else if (char === '?') {
1319
+ // ? matches exactly one character
1320
+ if (textIdx >= text.length) {
1321
+ return false;
1322
+ }
1323
+ textIdx++;
1324
+ patternIdx++;
1325
+ }
1326
+ else if (char === '{') {
1327
+ // {a,b,c} matches any alternative
1328
+ const closeIdx = findClosingBrace(pattern, patternIdx);
1329
+ if (closeIdx === -1) {
1330
+ // Unmatched brace, treat as literal
1331
+ if (textIdx >= text.length || text[textIdx] !== char) {
1332
+ return false;
1333
+ }
1334
+ textIdx++;
1335
+ patternIdx++;
1336
+ }
1337
+ else {
1338
+ const alternatives = extractAlternatives(pattern.slice(patternIdx + 1, closeIdx));
1339
+ for (const alt of alternatives) {
1340
+ if (matchSegmentFrom(text, textIdx, text.slice(0, textIdx) + alt + pattern.slice(closeIdx + 1), textIdx)) {
1341
+ return true;
1342
+ }
1343
+ }
1344
+ return false;
1345
+ }
1346
+ }
1347
+ else {
1348
+ // Literal character
1349
+ if (textIdx >= text.length || text[textIdx] !== char) {
1350
+ return false;
1351
+ }
1352
+ textIdx++;
1353
+ patternIdx++;
1354
+ }
1355
+ }
1356
+ return textIdx === text.length;
1357
+ }
1358
+ /**
1359
+ * Helper to match from a specific position.
1360
+ *
1361
+ * @param text - The full text being matched
1362
+ * @param textIdx - The starting index in text to match from
1363
+ * @param pattern - The full pattern being matched
1364
+ * @param patternIdx - The starting index in pattern to match from
1365
+ * @returns True if the text matches the pattern from the given positions
1366
+ */
1367
+ function matchSegmentFrom(text, textIdx, pattern, patternIdx) {
1368
+ const remainingText = text.slice(textIdx);
1369
+ const remainingPattern = pattern.slice(patternIdx);
1370
+ return matchSegment(remainingText, remainingPattern);
1371
+ }
1372
+ /**
1373
+ * Find closing brace for {a,b,c} pattern.
1374
+ *
1375
+ * @param pattern - The pattern string to search within
1376
+ * @param startIdx - The index of the opening brace
1377
+ * @returns The index of the matching closing brace, or -1 if not found
1378
+ */
1379
+ function findClosingBrace(pattern, startIdx) {
1380
+ let depth = 0;
1381
+ for (let i = startIdx; i < pattern.length; i++) {
1382
+ if (pattern[i] === '{') {
1383
+ depth++;
1384
+ }
1385
+ else if (pattern[i] === '}') {
1386
+ depth--;
1387
+ if (depth === 0) {
1388
+ return i;
1389
+ }
1390
+ }
1391
+ }
1392
+ return -1;
1393
+ }
1394
+ /**
1395
+ * Extract alternatives from {a,b,c} pattern content.
1396
+ *
1397
+ * @param content - The content between braces (without the braces themselves)
1398
+ * @returns Array of alternative strings split by commas at depth 0
1399
+ */
1400
+ function extractAlternatives(content) {
1401
+ const alternatives = [];
1402
+ let current = '';
1403
+ let depth = 0;
1404
+ for (let i = 0; i < content.length; i++) {
1405
+ const char = content[i];
1406
+ if (char === '{') {
1407
+ depth++;
1408
+ current += char;
1409
+ }
1410
+ else if (char === '}') {
1411
+ depth--;
1412
+ current += char;
1413
+ }
1414
+ else if (char === ',' && depth === 0) {
1415
+ alternatives.push(current);
1416
+ current = '';
1417
+ }
1418
+ else {
1419
+ current += char;
1420
+ }
1421
+ }
1422
+ if (current) {
1423
+ alternatives.push(current);
1424
+ }
1425
+ return alternatives;
1426
+ }
1427
+
1428
+ const walkLogger = createScopedLogger('project-scope:project:walk');
1429
+ /**
1430
+ * Reads .gitignore file from the given directory and extracts
1431
+ * non-comment patterns for use in file traversal filtering.
1432
+ *
1433
+ * @param startPath - Directory containing the .gitignore file
1434
+ * @returns Array of gitignore patterns
1435
+ */
1436
+ function loadGitignorePatterns(startPath) {
1437
+ const patterns = [];
1438
+ const gitignorePath = node_path.join(startPath, '.gitignore');
1439
+ const content = readFileIfExists(gitignorePath);
1440
+ if (content) {
1441
+ const lines = content.split('\n');
1442
+ for (const line of lines) {
1443
+ const trimmed = line.trim();
1444
+ if (trimmed && !trimmed.startsWith('#')) {
1445
+ patterns.push(trimmed);
1446
+ }
1447
+ }
1448
+ }
1449
+ return patterns;
1450
+ }
1451
+ /**
1452
+ * Evaluates whether a relative path should be ignored based on
1453
+ * a list of gitignore-style patterns.
1454
+ *
1455
+ * @param relativePath - Path relative to the root directory
1456
+ * @param patterns - Array of gitignore-style patterns to test
1457
+ * @returns True if the path matches any ignore pattern
1458
+ */
1459
+ function matchesIgnorePattern(relativePath, patterns) {
1460
+ for (const pattern of patterns) {
1461
+ if (matchPattern(relativePath, pattern)) {
1462
+ return true;
1463
+ }
1464
+ }
1465
+ return false;
1466
+ }
1467
+ /**
1468
+ * Tests if the given path matches a gitignore-style pattern,
1469
+ * supporting negation patterns with '!' prefix.
1470
+ * Uses safe character-by-character matching to prevent ReDoS attacks.
1471
+ *
1472
+ * @param path - File or directory path to test
1473
+ * @param pattern - Gitignore-style pattern (may include wildcards)
1474
+ * @returns True if the path matches the pattern (or doesn't match if negated)
1475
+ */
1476
+ function matchPattern(path, pattern) {
1477
+ const normalizedPattern = pattern.startsWith('/') ? pattern.slice(1) : pattern;
1478
+ const isNegation = normalizedPattern.startsWith('!');
1479
+ const actualPattern = isNegation ? normalizedPattern.slice(1) : normalizedPattern;
1480
+ const matchesFullPath = matchGlobPattern(path, actualPattern) || matchGlobPattern(path, `**/${actualPattern}`);
1481
+ const matchesSegment = path.split('/').some((segment) => matchGlobPattern(segment, actualPattern));
1482
+ const matches = matchesFullPath || matchesSegment;
1483
+ return isNegation ? !matches : matches;
1484
+ }
1485
+ /**
1486
+ * Traverses a directory tree synchronously, calling a visitor function
1487
+ * for each file and directory encountered. Supports depth limiting,
1488
+ * hidden file filtering, and gitignore pattern matching.
1489
+ *
1490
+ * @param startPath - Root directory to begin traversal
1491
+ * @param visitor - Callback function invoked for each file system entry
1492
+ * @param options - Configuration for traversal behavior
1493
+ */
1494
+ function walkDirectory(startPath, visitor, options) {
1495
+ walkLogger.debug('Starting directory walk', {
1496
+ startPath,
1497
+ maxDepth: options?.maxDepth ?? -1,
1498
+ includeHidden: options?.includeHidden ?? false,
1499
+ respectGitignore: options?.respectGitignore ?? true,
1500
+ ignorePatterns: options?.ignorePatterns?.length ?? 0,
1501
+ });
1502
+ const maxDepth = options?.maxDepth ?? -1;
1503
+ const includeHidden = options?.includeHidden ?? false;
1504
+ const ignorePatterns = options?.ignorePatterns ?? [];
1505
+ const respectGitignore = options?.respectGitignore ?? true;
1506
+ const gitignorePatterns = respectGitignore ? loadGitignorePatterns(startPath) : [];
1507
+ const allIgnorePatterns = [...ignorePatterns, ...gitignorePatterns];
1508
+ if (gitignorePatterns.length > 0) {
1509
+ walkLogger.debug('Loaded gitignore patterns', { count: gitignorePatterns.length });
1510
+ }
1511
+ /**
1512
+ * Recursively walks directory entries, applying visitor to each.
1513
+ *
1514
+ * @param currentPath - Absolute path to current directory
1515
+ * @param relativePath - Path relative to the starting directory
1516
+ * @param depth - Current recursion depth
1517
+ * @returns False to stop walking, true to continue
1518
+ */
1519
+ function walk(currentPath, relativePath, depth) {
1520
+ if (maxDepth !== -1 && depth > maxDepth) {
1521
+ return true;
1522
+ }
1523
+ let entries;
1524
+ try {
1525
+ entries = readDirectory(currentPath);
1526
+ }
1527
+ catch {
1528
+ return true;
1529
+ }
1530
+ for (const entry of entries) {
1531
+ if (!includeHidden && entry.name.startsWith('.')) {
1532
+ continue;
1533
+ }
1534
+ const entryRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
1535
+ if (matchesIgnorePattern(entryRelativePath, allIgnorePatterns)) {
1536
+ continue;
1537
+ }
1538
+ const walkEntry = {
1539
+ name: entry.name,
1540
+ path: entry.path,
1541
+ relativePath: entryRelativePath,
1542
+ isFile: entry.isFile,
1543
+ isDirectory: entry.isDirectory,
1544
+ isSymlink: entry.isSymlink,
1545
+ depth,
1546
+ };
1547
+ const result = visitor(walkEntry);
1548
+ if (result === 'stop') {
1549
+ return false;
1550
+ }
1551
+ if (result === 'skip') {
1552
+ continue;
1553
+ }
1554
+ if (entry.isDirectory) {
1555
+ const shouldContinue = walk(entry.path, entryRelativePath, depth + 1);
1556
+ if (!shouldContinue) {
1557
+ return false;
1558
+ }
1559
+ }
1560
+ }
1561
+ return true;
1562
+ }
1563
+ walk(startPath, '', 0);
1564
+ walkLogger.debug('Directory walk complete', { startPath });
1565
+ }
929
1566
 
930
- createScopedLogger('project-scope:project:search');
1567
+ const searchLogger = createScopedLogger('project-scope:project:search');
1568
+ /**
1569
+ * Tests if a path matches at least one pattern from an array of globs,
1570
+ * enabling flexible multi-pattern file filtering.
1571
+ * Uses safe character-by-character matching to prevent ReDoS attacks.
1572
+ *
1573
+ * @param path - File path to test
1574
+ * @param patterns - Array of glob patterns
1575
+ * @returns True if path matches any pattern
1576
+ */
1577
+ function matchesPatterns(path, patterns) {
1578
+ return patterns.some((pattern) => matchGlobPattern(path, pattern));
1579
+ }
1580
+ /**
1581
+ * Searches a directory tree for files matching one or more glob patterns,
1582
+ * returning relative or absolute paths based on options.
1583
+ *
1584
+ * @param startPath - Root directory to begin the search
1585
+ * @param patterns - Glob patterns (e.g., '*.ts', '**\/*.json') to filter files
1586
+ * @param options - Configuration for search behavior
1587
+ * @returns List of relative file paths that match the patterns
1588
+ *
1589
+ * @example
1590
+ * ```typescript
1591
+ * import { findFiles } from '@hyperfrontend/project-scope'
1592
+ *
1593
+ * // Find all TypeScript files
1594
+ * const tsFiles = findFiles('./src', '\*\*\/*.ts')
1595
+ *
1596
+ * // Find multiple file types
1597
+ * const configFiles = findFiles('./', ['\*.json', '\*.yaml', '\*.yml'])
1598
+ *
1599
+ * // Limit results and get absolute paths
1600
+ * const first10 = findFiles('./src', '\*\*\/*.ts', {
1601
+ * maxResults: 10,
1602
+ * absolutePaths: true
1603
+ * })
1604
+ * ```
1605
+ */
1606
+ function findFiles(startPath, patterns, options) {
1607
+ const normalizedPatterns = isArray(patterns) ? patterns : [patterns];
1608
+ searchLogger.debug('Finding files', { startPath, patterns: normalizedPatterns, maxResults: options?.maxResults });
1609
+ const results = [];
1610
+ const maxResults = options?.maxResults ?? Infinity;
1611
+ walkDirectory(startPath, (entry) => {
1612
+ if (results.length >= maxResults) {
1613
+ return 'stop';
1614
+ }
1615
+ if (!entry.isFile) {
1616
+ return undefined;
1617
+ }
1618
+ if (matchesPatterns(entry.relativePath, normalizedPatterns)) {
1619
+ results.push(options?.absolutePaths ? entry.path : entry.relativePath);
1620
+ }
1621
+ return undefined;
1622
+ }, options);
1623
+ searchLogger.debug('File search complete', { startPath, matchCount: results.length });
1624
+ return results;
1625
+ }
931
1626
 
932
1627
  createScopedLogger('project-scope:heuristics:entry-points');
933
1628
  /**
@@ -938,20 +1633,341 @@ createCache$1({ ttl: 60000, maxSize: 50 });
938
1633
 
939
1634
  createScopedLogger('project-scope:tech');
940
1635
  /**
941
- * Cache for tech detection results.
942
- * TTL: 60 seconds (tech stack can change during active development)
943
- */
944
- createCache$1({ ttl: 60000, maxSize: 50 });
945
-
946
- createScopedLogger('project-scope:heuristics:project-type');
947
-
948
- createScopedLogger('project-scope:root');
949
-
950
- createScopedLogger('project-scope:nx');
951
-
952
- createScopedLogger('project-scope:nx:devkit');
953
-
954
- createScopedLogger('project-scope:nx:config');
1636
+ * Cache for tech detection results.
1637
+ * TTL: 60 seconds (tech stack can change during active development)
1638
+ */
1639
+ createCache$1({ ttl: 60000, maxSize: 50 });
1640
+
1641
+ createScopedLogger('project-scope:heuristics:project-type');
1642
+
1643
+ const rootLogger = createScopedLogger('project-scope:root');
1644
+ /**
1645
+ * Files indicating workspace/monorepo root.
1646
+ */
1647
+ const WORKSPACE_MARKERS = ['nx.json', 'turbo.json', 'lerna.json', 'pnpm-workspace.yaml', 'rush.json'];
1648
+ /**
1649
+ * Find workspace root (monorepo root).
1650
+ * Searches up for workspace markers like nx.json, turbo.json, etc.
1651
+ *
1652
+ * @param startPath - Starting path
1653
+ * @returns Workspace root path or null
1654
+ *
1655
+ * @example
1656
+ * ```typescript
1657
+ * import { findWorkspaceRoot } from '@hyperfrontend/project-scope'
1658
+ *
1659
+ * const root = findWorkspaceRoot('./libs/my-lib')
1660
+ * if (root) {
1661
+ * console.log('Monorepo root:', root) // e.g., '/home/user/my-monorepo'
1662
+ * }
1663
+ * ```
1664
+ */
1665
+ function findWorkspaceRoot(startPath) {
1666
+ rootLogger.debug('Finding workspace root', { startPath });
1667
+ const byMarker = locateByMarkers(startPath, WORKSPACE_MARKERS);
1668
+ if (byMarker) {
1669
+ rootLogger.debug('Found workspace root by marker', { root: byMarker });
1670
+ return byMarker;
1671
+ }
1672
+ const byWorkspaces = findUpwardWhere(startPath, (dir) => {
1673
+ const pkg = readPackageJsonIfExists(dir);
1674
+ return pkg?.workspaces !== undefined;
1675
+ });
1676
+ if (byWorkspaces) {
1677
+ rootLogger.debug('Found workspace root by workspaces field', { root: byWorkspaces });
1678
+ return byWorkspaces;
1679
+ }
1680
+ const byPackage = findNearestPackageJson(startPath);
1681
+ if (byPackage) {
1682
+ rootLogger.debug('Found workspace root by package.json', { root: byPackage });
1683
+ }
1684
+ else {
1685
+ rootLogger.debug('Workspace root not found');
1686
+ }
1687
+ return byPackage;
1688
+ }
1689
+
1690
+ const nxLogger = createScopedLogger('project-scope:nx');
1691
+ /**
1692
+ * Files indicating NX workspace root.
1693
+ */
1694
+ const NX_CONFIG_FILES = ['nx.json', 'workspace.json'];
1695
+ /**
1696
+ * NX-specific project file.
1697
+ */
1698
+ const NX_PROJECT_FILE = 'project.json';
1699
+ /**
1700
+ * Check if directory is an NX workspace root.
1701
+ *
1702
+ * @param path - Directory path to check
1703
+ * @returns True if the directory contains nx.json or workspace.json
1704
+ *
1705
+ * @example
1706
+ * ```typescript
1707
+ * import { isNxWorkspace } from '@hyperfrontend/project-scope'
1708
+ *
1709
+ * if (isNxWorkspace('./my-project')) {
1710
+ * console.log('This is an NX monorepo')
1711
+ * }
1712
+ * ```
1713
+ */
1714
+ function isNxWorkspace(path) {
1715
+ for (const configFile of NX_CONFIG_FILES) {
1716
+ if (exists(node_path.join(path, configFile))) {
1717
+ nxLogger.debug('NX workspace detected', { path, configFile });
1718
+ return true;
1719
+ }
1720
+ }
1721
+ nxLogger.debug('Not an NX workspace', { path });
1722
+ return false;
1723
+ }
1724
+ /**
1725
+ * Check if directory is an NX project.
1726
+ *
1727
+ * @param path - Directory path to check
1728
+ * @returns True if the directory contains project.json
1729
+ */
1730
+ function isNxProject(path) {
1731
+ const isProject = exists(node_path.join(path, NX_PROJECT_FILE));
1732
+ nxLogger.debug('NX project check', { path, isProject });
1733
+ return isProject;
1734
+ }
1735
+ /**
1736
+ * Detect NX version from package.json dependencies.
1737
+ *
1738
+ * @param workspacePath - Workspace root path
1739
+ * @returns NX version string (without semver range) or null
1740
+ */
1741
+ function detectNxVersion(workspacePath) {
1742
+ const packageJson = readPackageJsonIfExists(workspacePath);
1743
+ if (packageJson) {
1744
+ const nxVersion = packageJson.devDependencies?.['nx'] ?? packageJson.dependencies?.['nx'];
1745
+ if (nxVersion) {
1746
+ // Strip semver range characters (^, ~, >=, etc.)
1747
+ return nxVersion.replace(/^[\^~>=<]+/, '');
1748
+ }
1749
+ }
1750
+ return null;
1751
+ }
1752
+ /**
1753
+ * Check if workspace is integrated (not standalone).
1754
+ * Integrated repos typically have workspaceLayout, namedInputs, or targetDefaults.
1755
+ *
1756
+ * @param nxJson - Parsed nx.json configuration
1757
+ * @returns True if the workspace is integrated
1758
+ */
1759
+ function isIntegratedRepo(nxJson) {
1760
+ return nxJson.workspaceLayout !== undefined || nxJson.namedInputs !== undefined || nxJson.targetDefaults !== undefined;
1761
+ }
1762
+ /**
1763
+ * Get comprehensive NX workspace information.
1764
+ *
1765
+ * @param workspacePath - Workspace root path
1766
+ * @returns Workspace info or null if not an NX workspace
1767
+ */
1768
+ function getNxWorkspaceInfo(workspacePath) {
1769
+ nxLogger.debug('Getting NX workspace info', { workspacePath });
1770
+ if (!isNxWorkspace(workspacePath)) {
1771
+ return null;
1772
+ }
1773
+ const nxJson = readJsonFileIfExists(node_path.join(workspacePath, 'nx.json'));
1774
+ if (!nxJson) {
1775
+ // Check for workspace.json as fallback (older NX)
1776
+ const workspaceJson = readJsonFileIfExists(node_path.join(workspacePath, 'workspace.json'));
1777
+ if (!workspaceJson) {
1778
+ nxLogger.debug('No nx.json or workspace.json found', { workspacePath });
1779
+ return null;
1780
+ }
1781
+ nxLogger.debug('Using legacy workspace.json', { workspacePath });
1782
+ // Create minimal nx.json from workspace.json
1783
+ return {
1784
+ root: workspacePath,
1785
+ version: detectNxVersion(workspacePath),
1786
+ nxJson: {},
1787
+ isIntegrated: true,
1788
+ workspaceLayout: {
1789
+ appsDir: 'apps',
1790
+ libsDir: 'libs',
1791
+ },
1792
+ };
1793
+ }
1794
+ const info = {
1795
+ root: workspacePath,
1796
+ version: detectNxVersion(workspacePath),
1797
+ nxJson,
1798
+ isIntegrated: isIntegratedRepo(nxJson),
1799
+ defaultProject: nxJson.defaultProject,
1800
+ workspaceLayout: {
1801
+ appsDir: nxJson.workspaceLayout?.appsDir ?? 'apps',
1802
+ libsDir: nxJson.workspaceLayout?.libsDir ?? 'libs',
1803
+ },
1804
+ };
1805
+ nxLogger.debug('NX workspace info retrieved', {
1806
+ workspacePath,
1807
+ version: info.version,
1808
+ isIntegrated: info.isIntegrated,
1809
+ defaultProject: info.defaultProject,
1810
+ });
1811
+ return info;
1812
+ }
1813
+
1814
+ createScopedLogger('project-scope:nx:devkit');
1815
+
1816
+ const nxConfigLogger = createScopedLogger('project-scope:nx:config');
1817
+ /**
1818
+ * Read project.json for an NX project.
1819
+ *
1820
+ * @param projectPath - Project directory path
1821
+ * @returns Parsed project.json or null if not found
1822
+ */
1823
+ function readProjectJson(projectPath) {
1824
+ const projectJsonPath = node_path.join(projectPath, NX_PROJECT_FILE);
1825
+ nxConfigLogger.debug('Reading project.json', { path: projectJsonPath });
1826
+ const result = readJsonFileIfExists(projectJsonPath);
1827
+ if (result) {
1828
+ nxConfigLogger.debug('Project.json loaded', { path: projectJsonPath, name: result.name });
1829
+ }
1830
+ else {
1831
+ nxConfigLogger.debug('Project.json not found', { path: projectJsonPath });
1832
+ }
1833
+ return result;
1834
+ }
1835
+ /**
1836
+ * Get project configuration from project.json or package.json nx field.
1837
+ *
1838
+ * @param projectPath - Project directory path
1839
+ * @param workspacePath - Workspace root path (for relative path calculation)
1840
+ * @returns Project configuration or null if not found
1841
+ */
1842
+ function getProjectConfig(projectPath, workspacePath) {
1843
+ nxConfigLogger.debug('Getting project config', { projectPath, workspacePath });
1844
+ // Try project.json first
1845
+ const projectJson = readProjectJson(projectPath);
1846
+ if (projectJson) {
1847
+ nxConfigLogger.debug('Using project.json config', { projectPath, name: projectJson.name });
1848
+ return {
1849
+ ...projectJson,
1850
+ root: projectJson.root ?? node_path.relative(workspacePath, projectPath),
1851
+ };
1852
+ }
1853
+ // Try to infer from package.json nx field
1854
+ const packageJson = readPackageJsonIfExists(projectPath);
1855
+ if (packageJson && typeof packageJson['nx'] === 'object') {
1856
+ nxConfigLogger.debug('Using package.json nx field', { projectPath, name: packageJson.name });
1857
+ const nxConfig = packageJson['nx'];
1858
+ return {
1859
+ name: packageJson.name,
1860
+ root: node_path.relative(workspacePath, projectPath),
1861
+ ...nxConfig,
1862
+ };
1863
+ }
1864
+ nxConfigLogger.debug('No project config found', { projectPath });
1865
+ return null;
1866
+ }
1867
+ /**
1868
+ * Recursively scan directory for project.json files.
1869
+ *
1870
+ * @param dirPath - Directory to scan
1871
+ * @param workspacePath - Workspace root path
1872
+ * @param projects - Map to add discovered projects to
1873
+ * @param maxDepth - Maximum recursion depth
1874
+ * @param currentDepth - Current recursion depth
1875
+ */
1876
+ function scanForProjects(dirPath, workspacePath, projects, maxDepth, currentDepth = 0) {
1877
+ if (currentDepth > maxDepth)
1878
+ return;
1879
+ try {
1880
+ const entries = readDirectory(dirPath);
1881
+ for (const entry of entries) {
1882
+ // Skip node_modules and hidden directories
1883
+ if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === 'dist') {
1884
+ continue;
1885
+ }
1886
+ const fullPath = node_path.join(dirPath, entry.name);
1887
+ if (entry.isDirectory) {
1888
+ // Check if this directory is an NX project
1889
+ if (isNxProject(fullPath)) {
1890
+ const config = getProjectConfig(fullPath, workspacePath);
1891
+ if (config) {
1892
+ const name = config.name || node_path.relative(workspacePath, fullPath).replace(/[\\/]/g, '-');
1893
+ projects.set(name, {
1894
+ ...config,
1895
+ name,
1896
+ root: node_path.relative(workspacePath, fullPath),
1897
+ });
1898
+ }
1899
+ }
1900
+ // Recursively scan subdirectories
1901
+ scanForProjects(fullPath, workspacePath, projects, maxDepth, currentDepth + 1);
1902
+ }
1903
+ }
1904
+ }
1905
+ catch {
1906
+ // Directory not readable, skip
1907
+ }
1908
+ }
1909
+ /**
1910
+ * Discover all NX projects in workspace.
1911
+ * Supports both workspace.json (older format) and project.json (newer format).
1912
+ *
1913
+ * @param workspacePath - Workspace root path
1914
+ * @returns Map of project name to configuration
1915
+ */
1916
+ function discoverNxProjects(workspacePath) {
1917
+ const projects = createMap();
1918
+ // Check for workspace.json (older NX format)
1919
+ const workspaceJson = readJsonFileIfExists(node_path.join(workspacePath, 'workspace.json'));
1920
+ if (workspaceJson?.projects) {
1921
+ for (const [name, config] of entries(workspaceJson.projects)) {
1922
+ if (typeof config === 'string') {
1923
+ // Path reference to project directory
1924
+ const projectPath = node_path.join(workspacePath, config);
1925
+ const projectConfig = getProjectConfig(projectPath, workspacePath);
1926
+ if (projectConfig) {
1927
+ projects.set(name, { ...projectConfig, name });
1928
+ }
1929
+ }
1930
+ else if (typeof config === 'object' && config !== null) {
1931
+ // Inline config
1932
+ projects.set(name, { name, ...config });
1933
+ }
1934
+ }
1935
+ return projects;
1936
+ }
1937
+ // Scan for project.json files (newer NX format)
1938
+ const workspaceInfo = getNxWorkspaceInfo(workspacePath);
1939
+ const appsDir = workspaceInfo?.workspaceLayout.appsDir ?? 'apps';
1940
+ const libsDir = workspaceInfo?.workspaceLayout.libsDir ?? 'libs';
1941
+ const searchDirs = [appsDir, libsDir];
1942
+ // Also check packages directory (common in some setups)
1943
+ if (exists(node_path.join(workspacePath, 'packages'))) {
1944
+ searchDirs.push('packages');
1945
+ }
1946
+ for (const dir of searchDirs) {
1947
+ const dirPath = node_path.join(workspacePath, dir);
1948
+ if (exists(dirPath) && isDirectory(dirPath)) {
1949
+ try {
1950
+ scanForProjects(dirPath, workspacePath, projects, 3);
1951
+ }
1952
+ catch {
1953
+ // Directory not accessible
1954
+ }
1955
+ }
1956
+ }
1957
+ // Also check root-level projects (standalone projects in monorepo root)
1958
+ if (isNxProject(workspacePath)) {
1959
+ const config = readProjectJson(workspacePath);
1960
+ if (config) {
1961
+ const name = config.name || node_path.basename(workspacePath);
1962
+ projects.set(name, {
1963
+ ...config,
1964
+ name,
1965
+ root: '.',
1966
+ });
1967
+ }
1968
+ }
1969
+ return projects;
1970
+ }
955
1971
 
956
1972
  createScopedLogger('project-scope:config');
957
1973
  /**
@@ -1823,6 +2839,45 @@ function commitExists(hash, options = {}) {
1823
2839
  return false;
1824
2840
  }
1825
2841
  }
2842
+ /**
2843
+ * Checks if a commit is reachable from HEAD (i.e., is an ancestor of HEAD).
2844
+ *
2845
+ * A commit may exist in the repository but be orphaned (not in current branch history).
2846
+ * This function verifies that the commit is actually in the history of the current HEAD.
2847
+ *
2848
+ * Common use cases:
2849
+ * - Verify an external commit reference before using it for range queries
2850
+ * - Detect if history was rewritten (rebase/force push) after a reference was recorded
2851
+ *
2852
+ * @param hash - Commit hash to check
2853
+ * @param options - Additional options
2854
+ * @returns True if the commit is an ancestor of HEAD
2855
+ *
2856
+ * @example
2857
+ * if (commitReachableFromHead(baseCommit)) {
2858
+ * // Safe to use for commit range queries
2859
+ * const commits = getCommitsSince(baseCommit)
2860
+ * } else {
2861
+ * // Commit not in current history, need fallback strategy
2862
+ * }
2863
+ */
2864
+ function commitReachableFromHead(hash, options = {}) {
2865
+ const safeHash = escapeGitRef(hash);
2866
+ try {
2867
+ // git merge-base --is-ancestor exits with 0 if commit is ancestor of HEAD
2868
+ node_child_process.execFileSync('git', ['merge-base', '--is-ancestor', safeHash, 'HEAD'], {
2869
+ encoding: 'utf-8',
2870
+ cwd: options.cwd,
2871
+ timeout: options.timeout ?? 5000,
2872
+ stdio: ['pipe', 'pipe', 'pipe'],
2873
+ });
2874
+ return true;
2875
+ }
2876
+ catch {
2877
+ // Exit code 1 means not an ancestor, other errors also return false
2878
+ return false;
2879
+ }
2880
+ }
1826
2881
  /**
1827
2882
  * Parses raw git log output into GitCommit objects.
1828
2883
  *
@@ -3465,6 +4520,7 @@ function createGitClient(config = {}) {
3465
4520
  getCommitsSince: (since, options) => getCommitsSince(since, { ...opts, ...options }),
3466
4521
  getCommit: (hash) => getCommit(hash, opts),
3467
4522
  commitExists: (hash) => commitExists(hash, opts),
4523
+ commitReachableFromHead: (hash) => commitReachableFromHead(hash, opts),
3468
4524
  // Tag operations
3469
4525
  getTags: (options) => getTags({ ...opts, ...options }),
3470
4526
  getTag: (name) => getTag(name, opts),
@@ -3508,6 +4564,7 @@ function createGitClient(config = {}) {
3508
4564
  fetch: (remote, options) => fetch(opts, remote, options),
3509
4565
  pull: (remote, branch) => pull(opts, remote, branch),
3510
4566
  push: (remote, branch, options) => push(opts, remote, branch, options),
4567
+ getRemoteUrl: (remoteName) => getRemoteUrl(opts, remoteName),
3511
4568
  };
3512
4569
  }
3513
4570
  // ============================================================================
@@ -3678,6 +4735,29 @@ function push(options, remote = 'origin', branch, pushOptions) {
3678
4735
  return false;
3679
4736
  }
3680
4737
  }
4738
+ /**
4739
+ * Gets the URL of a remote.
4740
+ *
4741
+ * @param options - Configuration object containing cwd and timeout
4742
+ * @param options.cwd - Working directory for the git command
4743
+ * @param options.timeout - Command timeout in milliseconds
4744
+ * @param remoteName - Name of the remote (defaults to 'origin')
4745
+ * @returns The remote URL, or null if not found
4746
+ */
4747
+ function getRemoteUrl(options, remoteName = 'origin') {
4748
+ try {
4749
+ const output = node_child_process.execFileSync('git', ['remote', 'get-url', remoteName], {
4750
+ encoding: 'utf-8',
4751
+ cwd: options.cwd,
4752
+ timeout: options.timeout,
4753
+ stdio: ['pipe', 'pipe', 'pipe'],
4754
+ });
4755
+ return output.trim() || null;
4756
+ }
4757
+ catch {
4758
+ return null;
4759
+ }
4760
+ }
3681
4761
 
3682
4762
  /**
3683
4763
  * Creates a new cache instance.
@@ -3883,6 +4963,7 @@ async function getVersionInfo(state, packageName, version) {
3883
4963
  engines: data.engines,
3884
4964
  nodeVersion: data._nodeVersion,
3885
4965
  npmVersion: data._npmVersion,
4966
+ gitHead: data.gitHead,
3886
4967
  };
3887
4968
  state.cache.set(cacheKey, info);
3888
4969
  return info;
@@ -3921,9 +5002,6 @@ async function listVersions(state, packageName) {
3921
5002
  return [];
3922
5003
  }
3923
5004
  }
3924
- // ============================================================================
3925
- // Security helpers - character-by-character validation (no regex)
3926
- // ============================================================================
3927
5005
  /**
3928
5006
  * Maximum allowed package name length (npm limit).
3929
5007
  */
@@ -4047,6 +5125,431 @@ function createRegistry(type = 'npm', config = {}) {
4047
5125
  }
4048
5126
  }
4049
5127
 
5128
+ /**
5129
+ * Project Model
5130
+ *
5131
+ * Represents a single project/package within a workspace.
5132
+ * Contains package.json data, paths, and dependency information.
5133
+ */
5134
+ /**
5135
+ * Creates a new Project object.
5136
+ *
5137
+ * @param options - Project properties
5138
+ * @returns A new Project object
5139
+ */
5140
+ function createProject(options) {
5141
+ const isPrivate = options.packageJson['private'] === true;
5142
+ const publishable = !isPrivate && options.name !== undefined && options.version !== undefined;
5143
+ return {
5144
+ name: options.name,
5145
+ version: options.version,
5146
+ path: options.path,
5147
+ packageJsonPath: options.packageJsonPath,
5148
+ packageJson: options.packageJson,
5149
+ changelogPath: options.changelogPath ?? null,
5150
+ internalDependencies: options.internalDependencies ?? [],
5151
+ internalDependents: options.internalDependents ?? [],
5152
+ publishable,
5153
+ private: isPrivate,
5154
+ };
5155
+ }
5156
+
5157
+ /**
5158
+ * Workspace Model
5159
+ *
5160
+ * Represents a monorepo workspace with multiple projects.
5161
+ * Used for package discovery, dependency tracking, and coordinated versioning.
5162
+ */
5163
+ /**
5164
+ * Default workspace discovery patterns.
5165
+ */
5166
+ const DEFAULT_PATTERNS = [
5167
+ 'libs/*/package.json',
5168
+ 'apps/*/package.json',
5169
+ 'packages/*/package.json',
5170
+ 'tools/*/package.json',
5171
+ 'plugins/*/package.json',
5172
+ ];
5173
+ /**
5174
+ * Default exclusion patterns.
5175
+ */
5176
+ const DEFAULT_EXCLUDE = ['**/node_modules/**', '**/dist/**', '**/coverage/**', '**/.git/**'];
5177
+ /**
5178
+ * Default workspace configuration.
5179
+ */
5180
+ const DEFAULT_WORKSPACE_CONFIG = {
5181
+ patterns: DEFAULT_PATTERNS,
5182
+ exclude: DEFAULT_EXCLUDE,
5183
+ includeChangelogs: true,
5184
+ trackDependencies: true,
5185
+ };
5186
+
5187
+ /**
5188
+ * Dependency Graph
5189
+ *
5190
+ * Builds and analyzes dependency relationships between workspace projects.
5191
+ * Provides functions for traversing the dependency graph and determining
5192
+ * build/release order.
5193
+ */
5194
+ /**
5195
+ * Finds internal dependencies in a package.json.
5196
+ * Returns names of workspace packages that this package depends on.
5197
+ *
5198
+ * @param packageJson - Parsed package.json content
5199
+ * @param workspacePackageNames - Set of all package names in the workspace
5200
+ * @returns Array of internal dependency names
5201
+ *
5202
+ * @example
5203
+ * ```typescript
5204
+ * const internalDeps = findInternalDependencies(packageJson, allPackageNames)
5205
+ * // ['@scope/lib-a', '@scope/lib-b']
5206
+ * ```
5207
+ */
5208
+ function findInternalDependencies(packageJson, workspacePackageNames) {
5209
+ const internal = [];
5210
+ const allDeps = {
5211
+ ...packageJson.dependencies,
5212
+ ...packageJson.devDependencies,
5213
+ ...packageJson.peerDependencies,
5214
+ ...packageJson.optionalDependencies,
5215
+ };
5216
+ for (const depName of keys(allDeps)) {
5217
+ if (workspacePackageNames.has(depName)) {
5218
+ internal.push(depName);
5219
+ }
5220
+ }
5221
+ return internal;
5222
+ }
5223
+
5224
+ /**
5225
+ * Changelog Discovery
5226
+ *
5227
+ * Discovers CHANGELOG.md files within workspace projects.
5228
+ */
5229
+ /**
5230
+ * Common changelog file names in priority order.
5231
+ */
5232
+ const CHANGELOG_NAMES = ['CHANGELOG.md', 'Changelog.md', 'changelog.md', 'HISTORY.md', 'CHANGES.md'];
5233
+ /**
5234
+ * Finds changelog files for a list of packages.
5235
+ * Returns a map of project path to changelog absolute path.
5236
+ *
5237
+ * @param workspaceRoot - Workspace root path
5238
+ * @param packages - List of packages to find changelogs for
5239
+ * @returns Map of project path to changelog path
5240
+ */
5241
+ function findChangelogs(workspaceRoot, packages) {
5242
+ const result = createMap();
5243
+ for (const pkg of packages) {
5244
+ const changelogPath = findProjectChangelog(pkg.path);
5245
+ if (changelogPath) {
5246
+ result.set(pkg.path, changelogPath);
5247
+ }
5248
+ }
5249
+ return result;
5250
+ }
5251
+ /**
5252
+ * Finds the changelog file for a single project.
5253
+ * Checks for common changelog names in order of priority.
5254
+ *
5255
+ * @param projectPath - Path to project directory
5256
+ * @returns Absolute path to changelog or null if not found
5257
+ *
5258
+ * @example
5259
+ * ```typescript
5260
+ * import { findProjectChangelog } from '@hyperfrontend/versioning'
5261
+ *
5262
+ * const changelogPath = findProjectChangelog('./libs/my-lib')
5263
+ * if (changelogPath) {
5264
+ * console.log('Found changelog:', changelogPath)
5265
+ * }
5266
+ * ```
5267
+ */
5268
+ function findProjectChangelog(projectPath) {
5269
+ for (const name of CHANGELOG_NAMES) {
5270
+ const changelogPath = node_path.join(projectPath, name);
5271
+ if (exists(changelogPath)) {
5272
+ return changelogPath;
5273
+ }
5274
+ }
5275
+ return null;
5276
+ }
5277
+
5278
+ /**
5279
+ * Discovers all packages within a workspace.
5280
+ * Finds package.json files, parses them, and optionally discovers
5281
+ * changelogs and internal dependencies.
5282
+ *
5283
+ * @param options - Discovery options
5284
+ * @returns Discovery result with all found packages
5285
+ * @throws {Error} If workspace root cannot be found
5286
+ *
5287
+ * @example
5288
+ * ```typescript
5289
+ * import { discoverPackages } from '@hyperfrontend/versioning'
5290
+ *
5291
+ * // Discover all packages in current workspace
5292
+ * const result = discoverPackages()
5293
+ *
5294
+ * // Discover with custom patterns
5295
+ * const result = discoverPackages({
5296
+ * patterns: ['packages/*\/package.json'],
5297
+ * includeChangelogs: true
5298
+ * })
5299
+ *
5300
+ * // Access discovered projects
5301
+ * for (const project of result.projects) {
5302
+ * console.log(`${project.name}@${project.version}`)
5303
+ * }
5304
+ * ```
5305
+ */
5306
+ function discoverPackages(options = {}) {
5307
+ // Resolve workspace root
5308
+ const workspaceRoot = options.workspaceRoot ?? findWorkspaceRoot(process.cwd());
5309
+ if (!workspaceRoot) {
5310
+ throw createError('Could not find workspace root. Ensure you are in a monorepo with nx.json, turbo.json, or workspaces field.');
5311
+ }
5312
+ // Build configuration
5313
+ const config = {
5314
+ patterns: options.patterns ?? DEFAULT_WORKSPACE_CONFIG.patterns,
5315
+ exclude: options.exclude ?? DEFAULT_WORKSPACE_CONFIG.exclude,
5316
+ includeChangelogs: options.includeChangelogs ?? DEFAULT_WORKSPACE_CONFIG.includeChangelogs,
5317
+ trackDependencies: options.trackDependencies ?? DEFAULT_WORKSPACE_CONFIG.trackDependencies,
5318
+ };
5319
+ // Find all package.json files
5320
+ const packageJsonPaths = findPackageJsonFiles(workspaceRoot, config);
5321
+ // Parse package.json files
5322
+ const rawPackages = parsePackageJsonFiles(workspaceRoot, packageJsonPaths);
5323
+ // Collect all package names for internal dependency detection
5324
+ const packageNames = createSet(rawPackages.map((p) => p.name));
5325
+ // Find changelogs if requested
5326
+ const changelogMap = config.includeChangelogs ? findChangelogs(workspaceRoot, rawPackages) : createMap();
5327
+ // Build projects with changelog paths
5328
+ const rawWithChangelogs = rawPackages.map((pkg) => ({
5329
+ ...pkg,
5330
+ changelogPath: changelogMap.get(pkg.path) ?? null,
5331
+ }));
5332
+ // Calculate internal dependencies
5333
+ const projects = config.trackDependencies
5334
+ ? buildProjectsWithDependencies(rawWithChangelogs, packageNames)
5335
+ : rawWithChangelogs.map((pkg) => createProject(pkg));
5336
+ // Build project map
5337
+ const projectMap = createMap();
5338
+ for (const project of projects) {
5339
+ projectMap.set(project.name, project);
5340
+ }
5341
+ return {
5342
+ projects,
5343
+ projectMap,
5344
+ packageNames,
5345
+ workspaceRoot,
5346
+ config,
5347
+ };
5348
+ }
5349
+ /**
5350
+ * Finds all package.json files matching the configured patterns.
5351
+ *
5352
+ * @param workspaceRoot - Root directory to search from
5353
+ * @param config - Workspace configuration
5354
+ * @returns Array of relative paths to package.json files
5355
+ */
5356
+ function findPackageJsonFiles(workspaceRoot, config) {
5357
+ const patterns = [...config.patterns];
5358
+ const excludePatterns = [...config.exclude];
5359
+ return findFiles(workspaceRoot, patterns, {
5360
+ ignorePatterns: excludePatterns,
5361
+ });
5362
+ }
5363
+ /**
5364
+ * Parses package.json files and extracts metadata.
5365
+ *
5366
+ * @param workspaceRoot - Workspace root path
5367
+ * @param packageJsonPaths - Relative paths to package.json files
5368
+ * @returns Array of raw package info objects
5369
+ */
5370
+ function parsePackageJsonFiles(workspaceRoot, packageJsonPaths) {
5371
+ const packages = [];
5372
+ for (const relativePath of packageJsonPaths) {
5373
+ const absolutePath = node_path.join(workspaceRoot, relativePath);
5374
+ const projectPath = node_path.dirname(absolutePath);
5375
+ try {
5376
+ const packageJson = readPackageJson(absolutePath);
5377
+ // Skip packages without a name
5378
+ if (!packageJson.name) {
5379
+ continue;
5380
+ }
5381
+ packages.push({
5382
+ name: packageJson.name,
5383
+ version: packageJson.version ?? '0.0.0',
5384
+ path: projectPath,
5385
+ packageJsonPath: absolutePath,
5386
+ packageJson,
5387
+ changelogPath: null,
5388
+ });
5389
+ }
5390
+ catch {
5391
+ // Skip packages that can't be parsed
5392
+ continue;
5393
+ }
5394
+ }
5395
+ return packages;
5396
+ }
5397
+ /**
5398
+ * Builds projects with internal dependency information.
5399
+ *
5400
+ * @param rawPackages - Raw package info objects
5401
+ * @param packageNames - Set of all package names
5402
+ * @returns Array of Project objects with dependencies populated
5403
+ */
5404
+ function buildProjectsWithDependencies(rawPackages, packageNames) {
5405
+ // First pass: create projects with dependencies
5406
+ const projectsWithDeps = [];
5407
+ for (const pkg of rawPackages) {
5408
+ const internalDeps = findInternalDependencies(pkg.packageJson, packageNames);
5409
+ projectsWithDeps.push({
5410
+ ...pkg,
5411
+ internalDependencies: internalDeps,
5412
+ });
5413
+ }
5414
+ // Build dependency -> dependents map
5415
+ const dependentsMap = createMap();
5416
+ for (const pkg of projectsWithDeps) {
5417
+ for (const dep of pkg.internalDependencies) {
5418
+ const existing = dependentsMap.get(dep) ?? [];
5419
+ existing.push(pkg.name);
5420
+ dependentsMap.set(dep, existing);
5421
+ }
5422
+ }
5423
+ // Second pass: add dependents to each project
5424
+ return projectsWithDeps.map((pkg) => {
5425
+ const dependents = dependentsMap.get(pkg.name) ?? [];
5426
+ return createProject({
5427
+ ...pkg,
5428
+ internalDependents: dependents,
5429
+ });
5430
+ });
5431
+ }
5432
+ /**
5433
+ * Discovers a project by name within a workspace.
5434
+ *
5435
+ * @param projectName - Name of the project to find
5436
+ * @param options - Discovery options
5437
+ * @returns The project or null if not found
5438
+ */
5439
+ function discoverProjectByName(projectName, options = {}) {
5440
+ const result = discoverPackages(options);
5441
+ return result.projectMap.get(projectName) ?? null;
5442
+ }
5443
+
5444
+ /**
5445
+ * Default project name prefixes that can be stripped for scope matching.
5446
+ */
5447
+ const DEFAULT_PROJECT_PREFIXES = ['lib-', 'app-', 'e2e-', 'tool-', 'plugin-', 'feature-', 'package-'];
5448
+ /**
5449
+ * Default scopes to exclude from changelogs.
5450
+ *
5451
+ * These represent repository-level or infrastructure changes
5452
+ * that typically don't belong in individual project changelogs.
5453
+ */
5454
+ const DEFAULT_EXCLUDE_SCOPES = ['release', 'deps', 'workspace', 'root', 'repo', 'ci', 'build'];
5455
+
5456
+ /**
5457
+ * Creates a matcher that checks if commit scope matches any of the given scopes.
5458
+ *
5459
+ * @param scopes - Scopes to match against (case-insensitive)
5460
+ * @returns Matcher that returns true if scope matches
5461
+ *
5462
+ * @example
5463
+ * const matcher = scopeMatcher(['ci', 'build', 'tooling'])
5464
+ * matcher({ scope: 'CI', ... }) // true
5465
+ * matcher({ scope: 'feat', ... }) // false
5466
+ */
5467
+ function scopeMatcher(scopes) {
5468
+ const normalizedScopes = createSet(scopes.map((s) => s.toLowerCase()));
5469
+ return (ctx) => {
5470
+ if (!ctx.scope)
5471
+ return false;
5472
+ return normalizedScopes.has(ctx.scope.toLowerCase());
5473
+ };
5474
+ }
5475
+ /**
5476
+ * Creates a matcher that checks if commit scope starts with any of the given prefixes.
5477
+ *
5478
+ * @param prefixes - Scope prefixes to match (case-insensitive)
5479
+ * @returns Matcher that returns true if scope starts with any prefix
5480
+ *
5481
+ * @example
5482
+ * const matcher = scopePrefixMatcher(['tool-', 'infra-'])
5483
+ * matcher({ scope: 'tool-package', ... }) // true
5484
+ * matcher({ scope: 'lib-utils', ... }) // false
5485
+ */
5486
+ function scopePrefixMatcher(prefixes) {
5487
+ const normalizedPrefixes = prefixes.map((p) => p.toLowerCase());
5488
+ return (ctx) => {
5489
+ if (!ctx.scope)
5490
+ return false;
5491
+ const normalizedScope = ctx.scope.toLowerCase();
5492
+ return normalizedPrefixes.some((prefix) => normalizedScope.startsWith(prefix));
5493
+ };
5494
+ }
5495
+ /**
5496
+ * Combines matchers with OR logic - returns true if ANY matcher matches.
5497
+ *
5498
+ * @param matchers - Matchers to combine
5499
+ * @returns Combined matcher
5500
+ *
5501
+ * @example
5502
+ * const combined = anyOf(
5503
+ * scopeMatcher(['ci', 'build']),
5504
+ * messageMatcher(['[infra]']),
5505
+ * custom((ctx) => ctx.scope?.startsWith('tool-'))
5506
+ * )
5507
+ */
5508
+ function anyOf(...matchers) {
5509
+ return (ctx) => matchers.some((matcher) => matcher(ctx));
5510
+ }
5511
+ /**
5512
+ * Matches common CI/CD scopes.
5513
+ *
5514
+ * Matches: ci, cd, build, pipeline, workflow, actions
5515
+ */
5516
+ const CI_SCOPE_MATCHER = scopeMatcher(['ci', 'cd', 'build', 'pipeline', 'workflow', 'actions']);
5517
+ /**
5518
+ * Matches common tooling/workspace scopes.
5519
+ *
5520
+ * Matches: tooling, workspace, monorepo, nx, root
5521
+ */
5522
+ const TOOLING_SCOPE_MATCHER = scopeMatcher(['tooling', 'workspace', 'monorepo', 'nx', 'root']);
5523
+ /**
5524
+ * Matches tool-prefixed scopes (e.g., tool-package, tool-scripts).
5525
+ */
5526
+ const TOOL_PREFIX_MATCHER = scopePrefixMatcher(['tool-']);
5527
+ /**
5528
+ * Combined matcher for common infrastructure patterns.
5529
+ *
5530
+ * Combines CI, tooling, and tool-prefix matchers.
5531
+ */
5532
+ anyOf(CI_SCOPE_MATCHER, TOOLING_SCOPE_MATCHER, TOOL_PREFIX_MATCHER);
5533
+
5534
+ /**
5535
+ * Default changelog filename.
5536
+ */
5537
+ const DEFAULT_CHANGELOG_FILENAME = 'CHANGELOG.md';
5538
+ /**
5539
+ * Default scope filtering configuration.
5540
+ *
5541
+ * Uses DEFAULT_EXCLUDE_SCOPES from commits/classify to ensure consistency
5542
+ * between flow-level filtering and commit classification.
5543
+ */
5544
+ const DEFAULT_SCOPE_FILTERING_CONFIG = {
5545
+ strategy: 'hybrid',
5546
+ includeScopes: [],
5547
+ excludeScopes: DEFAULT_EXCLUDE_SCOPES,
5548
+ trackDependencyChanges: false,
5549
+ projectPrefixes: DEFAULT_PROJECT_PREFIXES,
5550
+ infrastructure: undefined,
5551
+ infrastructureMatcher: undefined,
5552
+ };
4050
5553
  /**
4051
5554
  * Default flow configuration values.
4052
5555
  */
@@ -4067,50 +5570,93 @@ const DEFAULT_FLOW_CONFIG = {
4067
5570
  allowPrerelease: false,
4068
5571
  prereleaseId: 'alpha',
4069
5572
  releaseAs: undefined,
5573
+ maxCommitFallback: 500,
5574
+ repository: undefined,
5575
+ scopeFiltering: DEFAULT_SCOPE_FILTERING_CONFIG,
5576
+ changelogFileName: DEFAULT_CHANGELOG_FILENAME,
5577
+ commitTypeToSection: undefined,
4070
5578
  };
4071
5579
 
4072
5580
  /**
4073
- * Resolves the project root path from workspace root and project name.
5581
+ * Discovers project root using multiple strategies.
4074
5582
  *
4075
- * For now, uses a simple convention: libs/{projectName} or apps/{projectName}
4076
- * In a real implementation, this would query the workspace configuration.
5583
+ * Resolution order:
5584
+ * 1. Explicit `projectRoot` option (from Nx executor)
5585
+ * 2. Nx project discovery via `discoverNxProjects` (if in Nx workspace)
5586
+ * 3. Workspace discovery via `discoverProjectByName`
4077
5587
  *
4078
5588
  * @param workspaceRoot - Workspace root path
4079
5589
  * @param projectName - Project name (e.g., 'lib-versioning')
4080
- * @returns Absolute path to project root
4081
- */
4082
- function resolveProjectRoot(workspaceRoot, projectName) {
4083
- // Remove 'lib-' or 'app-' prefix to get the folder name
4084
- let folderName = projectName;
4085
- let prefix = 'libs';
4086
- if (projectName.startsWith('lib-')) {
4087
- folderName = projectName.slice(4);
4088
- prefix = 'libs';
5590
+ * @param providedRoot - Explicitly provided project root (optional)
5591
+ * @param logger - Logger instance
5592
+ * @returns Resolution result with path and source, or null if not found
5593
+ */
5594
+ function discoverProjectRoot(workspaceRoot, projectName, providedRoot, logger) {
5595
+ // 1. Explicit projectRoot provided (preferred - from Nx executor)
5596
+ if (providedRoot) {
5597
+ const projectRoot = providedRoot.startsWith(workspaceRoot) ? providedRoot : `${workspaceRoot}/${providedRoot}`;
5598
+ logger.debug(`Using provided project root: ${providedRoot}`);
5599
+ return { projectRoot, source: 'provided' };
5600
+ }
5601
+ // 2. Try Nx project discovery (fast, if we're in an Nx workspace)
5602
+ if (isNxWorkspace(workspaceRoot)) {
5603
+ logger.debug('Nx workspace detected, attempting Nx project discovery');
5604
+ try {
5605
+ const nxProjects = discoverNxProjects(workspaceRoot);
5606
+ const nxConfig = nxProjects.get(projectName);
5607
+ if (nxConfig?.root) {
5608
+ const projectRoot = `${workspaceRoot}/${nxConfig.root}`;
5609
+ logger.debug(`Discovered project root via Nx: ${nxConfig.root}`);
5610
+ return { projectRoot, source: 'nx-discovery' };
5611
+ }
5612
+ logger.debug(`Project "${projectName}" not found in Nx project graph`);
5613
+ }
5614
+ catch (error) {
5615
+ logger.debug(`Nx project discovery failed: ${error}`);
5616
+ }
5617
+ }
5618
+ // 3. Try workspace discovery (handles any monorepo structure)
5619
+ logger.debug('Attempting workspace discovery via discoverProjectByName');
5620
+ try {
5621
+ const project = discoverProjectByName(projectName, { workspaceRoot });
5622
+ if (project) {
5623
+ logger.debug(`Discovered project root via workspace discovery: ${project.path}`);
5624
+ return { projectRoot: project.path, source: 'workspace-discovery' };
5625
+ }
5626
+ logger.debug(`Project "${projectName}" not found via workspace discovery`);
4089
5627
  }
4090
- else if (projectName.startsWith('app-')) {
4091
- folderName = projectName.slice(4);
4092
- prefix = 'apps';
5628
+ catch (error) {
5629
+ logger.debug(`Workspace discovery failed: ${error}`);
4093
5630
  }
4094
- return `${workspaceRoot}/${prefix}/${folderName}`;
5631
+ // All discovery methods failed
5632
+ logger.error(`Could not discover project "${projectName}" in workspace "${workspaceRoot}". ` +
5633
+ `Ensure the project exists and has a valid package.json, or pass projectRoot explicitly.`);
5634
+ return null;
4095
5635
  }
4096
5636
  /**
4097
5637
  * Resolves the package name from the project root.
4098
5638
  *
4099
5639
  * @param tree - Virtual file system tree
4100
5640
  * @param projectRoot - Project root path
5641
+ * @param logger - Logger instance for diagnostics
4101
5642
  * @returns Package name from package.json
4102
5643
  */
4103
- function resolvePackageName(tree, projectRoot) {
5644
+ function resolvePackageName(tree, projectRoot, logger) {
4104
5645
  const packageJsonPath = `${projectRoot}/package.json`;
4105
5646
  try {
4106
5647
  const content = tree.read(packageJsonPath, 'utf-8');
4107
5648
  if (!content) {
5649
+ logger.debug(`package.json is empty or not found at ${packageJsonPath}`);
4108
5650
  return 'unknown';
4109
5651
  }
4110
5652
  const pkg = parse(content);
5653
+ if (!pkg.name) {
5654
+ logger.debug(`package.json at ${packageJsonPath} has no name field`);
5655
+ }
4111
5656
  return pkg.name ?? 'unknown';
4112
5657
  }
4113
- catch {
5658
+ catch (error) {
5659
+ logger.debug(`Failed to read package.json at ${packageJsonPath}: ${error}`);
4114
5660
  return 'unknown';
4115
5661
  }
4116
5662
  }
@@ -4187,9 +5733,37 @@ async function executeFlow(flow, projectName, workspaceRoot, options = {}) {
4187
5733
  const tree = options.tree ?? createTree(workspaceRoot);
4188
5734
  const registry = options.registry ?? createRegistry('npm');
4189
5735
  const git = options.git ?? createGitClient({ ...DEFAULT_GIT_CLIENT_CONFIG, cwd: workspaceRoot });
4190
- // Resolve paths
4191
- const projectRoot = resolveProjectRoot(workspaceRoot, projectName);
4192
- const packageName = resolvePackageName(tree, projectRoot);
5736
+ // Resolve project root with smart discovery
5737
+ const resolution = discoverProjectRoot(workspaceRoot, projectName, options.projectRoot, flowLogger);
5738
+ // Fail early if project cannot be discovered
5739
+ if (!resolution) {
5740
+ return {
5741
+ status: 'failed',
5742
+ steps: [],
5743
+ state: {},
5744
+ duration: dateNow() - startTime,
5745
+ summary: `Project "${projectName}" not found in workspace`,
5746
+ };
5747
+ }
5748
+ const { projectRoot, source: projectRootSource } = resolution;
5749
+ // Early validation: ensure project root is valid
5750
+ const packageJsonPath = `${projectRoot}/package.json`;
5751
+ if (!tree.exists(packageJsonPath)) {
5752
+ const errorMsg = `Project root validation failed: ${packageJsonPath} does not exist. ` +
5753
+ `Resolved projectRoot="${projectRoot}" (source: ${projectRootSource}) from projectName="${projectName}".`;
5754
+ flowLogger.error(errorMsg);
5755
+ return {
5756
+ status: 'failed',
5757
+ steps: [],
5758
+ state: {},
5759
+ duration: dateNow() - startTime,
5760
+ summary: `Invalid project root: ${projectRoot}`,
5761
+ };
5762
+ }
5763
+ const packageName = resolvePackageName(tree, projectRoot, flowLogger);
5764
+ if (packageName === 'unknown') {
5765
+ flowLogger.warn(`Could not read package name from ${packageJsonPath}`);
5766
+ }
4193
5767
  // Initialize context
4194
5768
  const context = {
4195
5769
  workspaceRoot,