thailint 0.4.2__py3-none-any.whl → 0.4.4__py3-none-any.whl

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.
src/cli.py CHANGED
@@ -59,12 +59,145 @@ def setup_logging(verbose: bool = False):
59
59
  )
60
60
 
61
61
 
62
+ def _determine_project_root(
63
+ explicit_root: str | None, config_path: str | None, verbose: bool
64
+ ) -> Path:
65
+ """Determine project root with precedence rules.
66
+
67
+ Precedence order:
68
+ 1. Explicit --project-root (highest priority)
69
+ 2. Inferred from --config path directory
70
+ 3. Auto-detection via get_project_root() (fallback)
71
+
72
+ Args:
73
+ explicit_root: Explicitly specified project root path (from --project-root)
74
+ config_path: Config file path (from --config)
75
+ verbose: Whether verbose logging is enabled
76
+
77
+ Returns:
78
+ Path to determined project root
79
+
80
+ Raises:
81
+ SystemExit: If explicit_root doesn't exist or is not a directory
82
+ """
83
+ from src.utils.project_root import get_project_root
84
+
85
+ # Priority 1: Explicit --project-root
86
+ if explicit_root:
87
+ return _resolve_explicit_project_root(explicit_root, verbose)
88
+
89
+ # Priority 2: Infer from --config path
90
+ if config_path:
91
+ return _infer_root_from_config(config_path, verbose)
92
+
93
+ # Priority 3: Auto-detection (fallback)
94
+ return _autodetect_project_root(verbose, get_project_root)
95
+
96
+
97
+ def _resolve_explicit_project_root(explicit_root: str, verbose: bool) -> Path:
98
+ """Resolve and validate explicitly specified project root.
99
+
100
+ Args:
101
+ explicit_root: Explicitly specified project root path
102
+ verbose: Whether verbose logging is enabled
103
+
104
+ Returns:
105
+ Resolved project root path
106
+
107
+ Raises:
108
+ SystemExit: If explicit_root doesn't exist or is not a directory
109
+ """
110
+ root = Path(explicit_root)
111
+ # Check existence before resolving to handle relative paths in test environments
112
+ if not root.exists():
113
+ click.echo(f"Error: Project root does not exist: {explicit_root}", err=True)
114
+ sys.exit(2)
115
+ if not root.is_dir():
116
+ click.echo(f"Error: Project root must be a directory: {explicit_root}", err=True)
117
+ sys.exit(2)
118
+
119
+ # Now resolve after validation
120
+ root = root.resolve()
121
+
122
+ if verbose:
123
+ logger.debug(f"Using explicit project root: {root}")
124
+ return root
125
+
126
+
127
+ def _infer_root_from_config(config_path: str, verbose: bool) -> Path:
128
+ """Infer project root from config file path.
129
+
130
+ Args:
131
+ config_path: Config file path
132
+ verbose: Whether verbose logging is enabled
133
+
134
+ Returns:
135
+ Inferred project root (parent directory of config file)
136
+ """
137
+ config_file = Path(config_path).resolve()
138
+ inferred_root = config_file.parent
139
+
140
+ if verbose:
141
+ logger.debug(f"Inferred project root from config path: {inferred_root}")
142
+ return inferred_root
143
+
144
+
145
+ def _autodetect_project_root(verbose: bool, get_project_root) -> Path:
146
+ """Auto-detect project root using project root detection.
147
+
148
+ Args:
149
+ verbose: Whether verbose logging is enabled
150
+ get_project_root: Function to detect project root
151
+
152
+ Returns:
153
+ Auto-detected project root
154
+ """
155
+ auto_root = get_project_root(None)
156
+ if verbose:
157
+ logger.debug(f"Auto-detected project root: {auto_root}")
158
+ return auto_root
159
+
160
+
161
+ def _get_project_root_from_context(ctx) -> Path:
162
+ """Get or determine project root from Click context.
163
+
164
+ This function defers the actual determination until needed to avoid
165
+ importing pyprojroot in test environments where it may not be available.
166
+
167
+ Args:
168
+ ctx: Click context containing CLI options
169
+
170
+ Returns:
171
+ Path to determined project root
172
+ """
173
+ # Check if already determined and cached
174
+ if "project_root" in ctx.obj:
175
+ return ctx.obj["project_root"]
176
+
177
+ # Determine project root using stored CLI options
178
+ explicit_root = ctx.obj.get("cli_project_root")
179
+ config_path = ctx.obj.get("cli_config_path")
180
+ verbose = ctx.obj.get("verbose", False)
181
+
182
+ project_root = _determine_project_root(explicit_root, config_path, verbose)
183
+
184
+ # Cache for future use
185
+ ctx.obj["project_root"] = project_root
186
+
187
+ return project_root
188
+
189
+
62
190
  @click.group()
63
191
  @click.version_option(version=__version__)
64
192
  @click.option("--verbose", "-v", is_flag=True, help="Enable verbose output")
65
193
  @click.option("--config", "-c", type=click.Path(), help="Path to config file")
194
+ @click.option(
195
+ "--project-root",
196
+ type=click.Path(),
197
+ help="Explicitly specify project root directory (overrides auto-detection)",
198
+ )
66
199
  @click.pass_context
67
- def cli(ctx, verbose: bool, config: str | None):
200
+ def cli(ctx, verbose: bool, config: str | None, project_root: str | None):
68
201
  """
69
202
  thai-lint - AI code linter and governance tool
70
203
 
@@ -85,6 +218,10 @@ def cli(ctx, verbose: bool, config: str | None):
85
218
  # Lint with custom config
86
219
  thai-lint file-placement --config .thailint.yaml src/
87
220
 
221
+ \b
222
+ # Specify project root explicitly (useful in Docker)
223
+ thai-lint --project-root /workspace/root magic-numbers backend/
224
+
88
225
  \b
89
226
  # Get JSON output
90
227
  thai-lint file-placement --format json .
@@ -99,6 +236,11 @@ def cli(ctx, verbose: bool, config: str | None):
99
236
  # Setup logging
100
237
  setup_logging(verbose)
101
238
 
239
+ # Store CLI options for later project root determination
240
+ # (deferred to avoid pyprojroot import issues in test environments)
241
+ ctx.obj["cli_project_root"] = project_root
242
+ ctx.obj["cli_config_path"] = config
243
+
102
244
  # Load configuration
103
245
  try:
104
246
  if config:
@@ -551,6 +693,7 @@ def file_placement( # pylint: disable=too-many-arguments,too-many-positional-ar
551
693
  thai-lint file-placement --rules '{"allow": [".*\\.py$"]}' .
552
694
  """
553
695
  verbose = ctx.obj.get("verbose", False)
696
+ project_root = _get_project_root_from_context(ctx)
554
697
 
555
698
  if not paths:
556
699
  paths = (".",)
@@ -558,17 +701,19 @@ def file_placement( # pylint: disable=too-many-arguments,too-many-positional-ar
558
701
  path_objs = [Path(p) for p in paths]
559
702
 
560
703
  try:
561
- _execute_file_placement_lint(path_objs, config_file, rules, format, recursive, verbose)
704
+ _execute_file_placement_lint(
705
+ path_objs, config_file, rules, format, recursive, verbose, project_root
706
+ )
562
707
  except Exception as e:
563
708
  _handle_linting_error(e, verbose)
564
709
 
565
710
 
566
711
  def _execute_file_placement_lint( # pylint: disable=too-many-arguments,too-many-positional-arguments
567
- path_objs, config_file, rules, format, recursive, verbose
712
+ path_objs, config_file, rules, format, recursive, verbose, project_root=None
568
713
  ):
569
714
  """Execute file placement linting."""
570
715
  _validate_paths_exist(path_objs)
571
- orchestrator = _setup_orchestrator(path_objs, config_file, rules, verbose)
716
+ orchestrator = _setup_orchestrator(path_objs, config_file, rules, verbose, project_root)
572
717
  all_violations = _execute_linting_on_paths(orchestrator, path_objs, recursive)
573
718
 
574
719
  # Filter to only file-placement violations
@@ -627,26 +772,55 @@ def _find_project_root(start_path: Path) -> Path:
627
772
  return get_project_root(start_path)
628
773
 
629
774
 
630
- def _setup_orchestrator(path_objs, config_file, rules, verbose):
775
+ def _setup_orchestrator(path_objs, config_file, rules, verbose, project_root=None):
631
776
  """Set up and configure the orchestrator."""
632
777
  from src.orchestrator.core import Orchestrator
633
778
  from src.utils.project_root import get_project_root
634
779
 
780
+ # Use provided project_root or fall back to auto-detection
781
+ project_root = _get_or_detect_project_root(path_objs, project_root, get_project_root)
782
+
783
+ orchestrator = Orchestrator(project_root=project_root)
784
+ _apply_orchestrator_config(orchestrator, config_file, rules, verbose)
785
+
786
+ return orchestrator
787
+
788
+
789
+ def _get_or_detect_project_root(path_objs, project_root, get_project_root):
790
+ """Get provided project root or auto-detect from paths.
791
+
792
+ Args:
793
+ path_objs: List of path objects
794
+ project_root: Optionally provided project root
795
+ get_project_root: Function to detect project root
796
+
797
+ Returns:
798
+ Project root path
799
+ """
800
+ if project_root is not None:
801
+ return project_root
802
+
635
803
  # Find actual project root (where .git or pyproject.toml exists)
636
804
  # This ensures .artifacts/ is always created at project root, not in subdirectories
637
805
  first_path = path_objs[0] if path_objs else Path.cwd()
638
806
  search_start = first_path if first_path.is_dir() else first_path.parent
639
- project_root = get_project_root(search_start)
807
+ return get_project_root(search_start)
640
808
 
641
- orchestrator = Orchestrator(project_root=project_root)
642
809
 
810
+ def _apply_orchestrator_config(orchestrator, config_file, rules, verbose):
811
+ """Apply configuration to orchestrator.
812
+
813
+ Args:
814
+ orchestrator: Orchestrator instance
815
+ config_file: Path to config file (optional)
816
+ rules: Inline JSON rules (optional)
817
+ verbose: Whether verbose logging is enabled
818
+ """
643
819
  if rules:
644
820
  _apply_inline_rules(orchestrator, rules, verbose)
645
821
  elif config_file:
646
822
  _load_config_file(orchestrator, config_file, verbose)
647
823
 
648
- return orchestrator
649
-
650
824
 
651
825
  def _apply_inline_rules(orchestrator, rules, verbose):
652
826
  """Parse and apply inline JSON rules."""
@@ -759,13 +933,18 @@ def _execute_linting_on_paths(orchestrator, path_objs: list[Path], recursive: bo
759
933
  return violations
760
934
 
761
935
 
762
- def _setup_nesting_orchestrator(path_objs: list[Path], config_file: str | None, verbose: bool):
936
+ def _setup_nesting_orchestrator(
937
+ path_objs: list[Path], config_file: str | None, verbose: bool, project_root: Path | None = None
938
+ ):
763
939
  """Set up orchestrator for nesting command."""
764
- # Use first path to determine project root
765
- first_path = path_objs[0] if path_objs else Path.cwd()
766
- project_root = first_path if first_path.is_dir() else first_path.parent
767
-
768
940
  from src.orchestrator.core import Orchestrator
941
+ from src.utils.project_root import get_project_root
942
+
943
+ # Use provided project_root or fall back to auto-detection
944
+ if project_root is None:
945
+ first_path = path_objs[0] if path_objs else Path.cwd()
946
+ search_start = first_path if first_path.is_dir() else first_path.parent
947
+ project_root = get_project_root(search_start)
769
948
 
770
949
  orchestrator = Orchestrator(project_root=project_root)
771
950
 
@@ -780,14 +959,34 @@ def _apply_nesting_config_override(orchestrator, max_depth: int | None, verbose:
780
959
  if max_depth is None:
781
960
  return
782
961
 
962
+ # Ensure nesting config exists
783
963
  if "nesting" not in orchestrator.config:
784
964
  orchestrator.config["nesting"] = {}
785
- orchestrator.config["nesting"]["max_nesting_depth"] = max_depth
965
+
966
+ nesting_config = orchestrator.config["nesting"]
967
+
968
+ # Set top-level max_nesting_depth
969
+ nesting_config["max_nesting_depth"] = max_depth
970
+
971
+ # Override language-specific configs to ensure CLI option takes precedence
972
+ _override_language_specific_nesting(nesting_config, max_depth)
786
973
 
787
974
  if verbose:
788
975
  logger.debug(f"Overriding max_nesting_depth to {max_depth}")
789
976
 
790
977
 
978
+ def _override_language_specific_nesting(nesting_config: dict, max_depth: int):
979
+ """Override language-specific nesting depth configs.
980
+
981
+ Args:
982
+ nesting_config: Nesting configuration dictionary
983
+ max_depth: Maximum nesting depth to set
984
+ """
985
+ for lang in ["python", "typescript", "javascript"]:
986
+ if lang in nesting_config:
987
+ nesting_config[lang]["max_nesting_depth"] = max_depth
988
+
989
+
791
990
  def _run_nesting_lint(orchestrator, path_objs: list[Path], recursive: bool):
792
991
  """Execute nesting lint on files or directories."""
793
992
  all_violations = _execute_linting_on_paths(orchestrator, path_objs, recursive)
@@ -851,6 +1050,7 @@ def nesting( # pylint: disable=too-many-arguments,too-many-positional-arguments
851
1050
  thai-lint nesting --config .thailint.yaml src/
852
1051
  """
853
1052
  verbose = ctx.obj.get("verbose", False)
1053
+ project_root = _get_project_root_from_context(ctx)
854
1054
 
855
1055
  # Default to current directory if no paths provided
856
1056
  if not paths:
@@ -859,17 +1059,19 @@ def nesting( # pylint: disable=too-many-arguments,too-many-positional-arguments
859
1059
  path_objs = [Path(p) for p in paths]
860
1060
 
861
1061
  try:
862
- _execute_nesting_lint(path_objs, config_file, format, max_depth, recursive, verbose)
1062
+ _execute_nesting_lint(
1063
+ path_objs, config_file, format, max_depth, recursive, verbose, project_root
1064
+ )
863
1065
  except Exception as e:
864
1066
  _handle_linting_error(e, verbose)
865
1067
 
866
1068
 
867
1069
  def _execute_nesting_lint( # pylint: disable=too-many-arguments,too-many-positional-arguments
868
- path_objs, config_file, format, max_depth, recursive, verbose
1070
+ path_objs, config_file, format, max_depth, recursive, verbose, project_root=None
869
1071
  ):
870
1072
  """Execute nesting lint."""
871
1073
  _validate_paths_exist(path_objs)
872
- orchestrator = _setup_nesting_orchestrator(path_objs, config_file, verbose)
1074
+ orchestrator = _setup_nesting_orchestrator(path_objs, config_file, verbose, project_root)
873
1075
  _apply_nesting_config_override(orchestrator, max_depth, verbose)
874
1076
  nesting_violations = _run_nesting_lint(orchestrator, path_objs, recursive)
875
1077
 
@@ -880,12 +1082,18 @@ def _execute_nesting_lint( # pylint: disable=too-many-arguments,too-many-positi
880
1082
  sys.exit(1 if nesting_violations else 0)
881
1083
 
882
1084
 
883
- def _setup_srp_orchestrator(path_objs: list[Path], config_file: str | None, verbose: bool):
1085
+ def _setup_srp_orchestrator(
1086
+ path_objs: list[Path], config_file: str | None, verbose: bool, project_root: Path | None = None
1087
+ ):
884
1088
  """Set up orchestrator for SRP command."""
885
- first_path = path_objs[0] if path_objs else Path.cwd()
886
- project_root = first_path if first_path.is_dir() else first_path.parent
887
-
888
1089
  from src.orchestrator.core import Orchestrator
1090
+ from src.utils.project_root import get_project_root
1091
+
1092
+ # Use provided project_root or fall back to auto-detection
1093
+ if project_root is None:
1094
+ first_path = path_objs[0] if path_objs else Path.cwd()
1095
+ search_start = first_path if first_path.is_dir() else first_path.parent
1096
+ project_root = get_project_root(search_start)
889
1097
 
890
1098
  orchestrator = Orchestrator(project_root=project_root)
891
1099
 
@@ -988,6 +1196,7 @@ def srp( # pylint: disable=too-many-arguments,too-many-positional-arguments
988
1196
  thai-lint srp --config .thailint.yaml src/
989
1197
  """
990
1198
  verbose = ctx.obj.get("verbose", False)
1199
+ project_root = _get_project_root_from_context(ctx)
991
1200
 
992
1201
  if not paths:
993
1202
  paths = (".",)
@@ -995,17 +1204,19 @@ def srp( # pylint: disable=too-many-arguments,too-many-positional-arguments
995
1204
  path_objs = [Path(p) for p in paths]
996
1205
 
997
1206
  try:
998
- _execute_srp_lint(path_objs, config_file, format, max_methods, max_loc, recursive, verbose)
1207
+ _execute_srp_lint(
1208
+ path_objs, config_file, format, max_methods, max_loc, recursive, verbose, project_root
1209
+ )
999
1210
  except Exception as e:
1000
1211
  _handle_linting_error(e, verbose)
1001
1212
 
1002
1213
 
1003
1214
  def _execute_srp_lint( # pylint: disable=too-many-arguments,too-many-positional-arguments
1004
- path_objs, config_file, format, max_methods, max_loc, recursive, verbose
1215
+ path_objs, config_file, format, max_methods, max_loc, recursive, verbose, project_root=None
1005
1216
  ):
1006
1217
  """Execute SRP lint."""
1007
1218
  _validate_paths_exist(path_objs)
1008
- orchestrator = _setup_srp_orchestrator(path_objs, config_file, verbose)
1219
+ orchestrator = _setup_srp_orchestrator(path_objs, config_file, verbose, project_root)
1009
1220
  _apply_srp_config_override(orchestrator, max_methods, max_loc, verbose)
1010
1221
  srp_violations = _run_srp_lint(orchestrator, path_objs, recursive)
1011
1222
 
@@ -1085,6 +1296,7 @@ def dry( # pylint: disable=too-many-arguments,too-many-positional-arguments
1085
1296
  thai-lint dry --format json .
1086
1297
  """
1087
1298
  verbose = ctx.obj.get("verbose", False)
1299
+ project_root = _get_project_root_from_context(ctx)
1088
1300
 
1089
1301
  if not paths:
1090
1302
  paths = (".",)
@@ -1093,18 +1305,34 @@ def dry( # pylint: disable=too-many-arguments,too-many-positional-arguments
1093
1305
 
1094
1306
  try:
1095
1307
  _execute_dry_lint(
1096
- path_objs, config_file, format, min_lines, no_cache, clear_cache, recursive, verbose
1308
+ path_objs,
1309
+ config_file,
1310
+ format,
1311
+ min_lines,
1312
+ no_cache,
1313
+ clear_cache,
1314
+ recursive,
1315
+ verbose,
1316
+ project_root,
1097
1317
  )
1098
1318
  except Exception as e:
1099
1319
  _handle_linting_error(e, verbose)
1100
1320
 
1101
1321
 
1102
1322
  def _execute_dry_lint( # pylint: disable=too-many-arguments,too-many-positional-arguments
1103
- path_objs, config_file, format, min_lines, no_cache, clear_cache, recursive, verbose
1323
+ path_objs,
1324
+ config_file,
1325
+ format,
1326
+ min_lines,
1327
+ no_cache,
1328
+ clear_cache,
1329
+ recursive,
1330
+ verbose,
1331
+ project_root=None,
1104
1332
  ):
1105
1333
  """Execute DRY linting."""
1106
1334
  _validate_paths_exist(path_objs)
1107
- orchestrator = _setup_dry_orchestrator(path_objs, config_file, verbose)
1335
+ orchestrator = _setup_dry_orchestrator(path_objs, config_file, verbose, project_root)
1108
1336
  _apply_dry_config_override(orchestrator, min_lines, no_cache, verbose)
1109
1337
 
1110
1338
  if clear_cache:
@@ -1119,14 +1347,16 @@ def _execute_dry_lint( # pylint: disable=too-many-arguments,too-many-positional
1119
1347
  sys.exit(1 if dry_violations else 0)
1120
1348
 
1121
1349
 
1122
- def _setup_dry_orchestrator(path_objs, config_file, verbose):
1350
+ def _setup_dry_orchestrator(path_objs, config_file, verbose, project_root=None):
1123
1351
  """Set up orchestrator for DRY linting."""
1124
1352
  from src.orchestrator.core import Orchestrator
1125
1353
  from src.utils.project_root import get_project_root
1126
1354
 
1127
- first_path = path_objs[0] if path_objs else Path.cwd()
1128
- search_start = first_path if first_path.is_dir() else first_path.parent
1129
- project_root = get_project_root(search_start)
1355
+ # Use provided project_root or fall back to auto-detection
1356
+ if project_root is None:
1357
+ first_path = path_objs[0] if path_objs else Path.cwd()
1358
+ search_start = first_path if first_path.is_dir() else first_path.parent
1359
+ project_root = get_project_root(search_start)
1130
1360
 
1131
1361
  orchestrator = Orchestrator(project_root=project_root)
1132
1362
 
@@ -1213,16 +1443,18 @@ def _run_dry_lint(orchestrator, path_objs, recursive):
1213
1443
 
1214
1444
 
1215
1445
  def _setup_magic_numbers_orchestrator(
1216
- path_objs: list[Path], config_file: str | None, verbose: bool
1446
+ path_objs: list[Path], config_file: str | None, verbose: bool, project_root: Path | None = None
1217
1447
  ):
1218
1448
  """Set up orchestrator for magic-numbers command."""
1219
1449
  from src.orchestrator.core import Orchestrator
1220
1450
  from src.utils.project_root import get_project_root
1221
1451
 
1222
- # Find actual project root (where .git or .thailint.yaml exists)
1223
- first_path = path_objs[0] if path_objs else Path.cwd()
1224
- search_start = first_path if first_path.is_dir() else first_path.parent
1225
- project_root = get_project_root(search_start)
1452
+ # Use provided project_root or fall back to auto-detection
1453
+ if project_root is None:
1454
+ # Find actual project root (where .git or .thailint.yaml exists)
1455
+ first_path = path_objs[0] if path_objs else Path.cwd()
1456
+ search_start = first_path if first_path.is_dir() else first_path.parent
1457
+ project_root = get_project_root(search_start)
1226
1458
 
1227
1459
  orchestrator = Orchestrator(project_root=project_root)
1228
1460
 
@@ -1289,6 +1521,7 @@ def magic_numbers( # pylint: disable=too-many-arguments,too-many-positional-arg
1289
1521
  thai-lint magic-numbers --config .thailint.yaml src/
1290
1522
  """
1291
1523
  verbose = ctx.obj.get("verbose", False)
1524
+ project_root = _get_project_root_from_context(ctx)
1292
1525
 
1293
1526
  if not paths:
1294
1527
  paths = (".",)
@@ -1296,17 +1529,19 @@ def magic_numbers( # pylint: disable=too-many-arguments,too-many-positional-arg
1296
1529
  path_objs = [Path(p) for p in paths]
1297
1530
 
1298
1531
  try:
1299
- _execute_magic_numbers_lint(path_objs, config_file, format, recursive, verbose)
1532
+ _execute_magic_numbers_lint(
1533
+ path_objs, config_file, format, recursive, verbose, project_root
1534
+ )
1300
1535
  except Exception as e:
1301
1536
  _handle_linting_error(e, verbose)
1302
1537
 
1303
1538
 
1304
1539
  def _execute_magic_numbers_lint( # pylint: disable=too-many-arguments,too-many-positional-arguments
1305
- path_objs, config_file, format, recursive, verbose
1540
+ path_objs, config_file, format, recursive, verbose, project_root=None
1306
1541
  ):
1307
1542
  """Execute magic-numbers lint."""
1308
1543
  _validate_paths_exist(path_objs)
1309
- orchestrator = _setup_magic_numbers_orchestrator(path_objs, config_file, verbose)
1544
+ orchestrator = _setup_magic_numbers_orchestrator(path_objs, config_file, verbose, project_root)
1310
1545
  magic_numbers_violations = _run_magic_numbers_lint(orchestrator, path_objs, recursive)
1311
1546
 
1312
1547
  if verbose:
@@ -62,6 +62,9 @@ class PythonDuplicateAnalyzer(BaseTokenAnalyzer): # thailint: ignore[srp.violat
62
62
  """
63
63
  super().__init__()
64
64
  self._filter_registry = filter_registry or create_default_registry()
65
+ # Performance optimization: Cache parsed AST to avoid re-parsing for each hash window
66
+ self._cached_ast: ast.Module | None = None
67
+ self._cached_content: str | None = None
65
68
 
66
69
  def analyze(self, file_path: Path, content: str, config: DRYConfig) -> list[CodeBlock]:
67
70
  """Analyze Python file for duplicate code blocks, excluding docstrings.
@@ -74,37 +77,46 @@ class PythonDuplicateAnalyzer(BaseTokenAnalyzer): # thailint: ignore[srp.violat
74
77
  Returns:
75
78
  List of CodeBlock instances with hash values
76
79
  """
77
- # Get docstring line ranges
78
- docstring_ranges = self._get_docstring_ranges_from_content(content)
80
+ # Performance optimization: Parse AST once and cache for _is_single_statement_in_source() calls
81
+ self._cached_ast = self._parse_content_safe(content)
82
+ self._cached_content = content
79
83
 
80
- # Tokenize with line number tracking
81
- lines_with_numbers = self._tokenize_with_line_numbers(content, docstring_ranges)
84
+ try:
85
+ # Get docstring line ranges
86
+ docstring_ranges = self._get_docstring_ranges_from_content(content)
82
87
 
83
- # Generate rolling hash windows
84
- windows = self._rolling_hash_with_tracking(lines_with_numbers, config.min_duplicate_lines)
88
+ # Tokenize with line number tracking
89
+ lines_with_numbers = self._tokenize_with_line_numbers(content, docstring_ranges)
85
90
 
86
- blocks = []
87
- for hash_val, start_line, end_line, snippet in windows:
88
- # Skip blocks that are single logical statements
89
- # Check the original source code, not the normalized snippet
90
- if self._is_single_statement_in_source(content, start_line, end_line):
91
- continue
91
+ # Generate rolling hash windows
92
+ windows = self._rolling_hash_with_tracking(lines_with_numbers, config.min_duplicate_lines)
92
93
 
93
- block = CodeBlock(
94
- file_path=file_path,
95
- start_line=start_line,
96
- end_line=end_line,
97
- snippet=snippet,
98
- hash_value=hash_val,
99
- )
94
+ blocks = []
95
+ for hash_val, start_line, end_line, snippet in windows:
96
+ # Skip blocks that are single logical statements
97
+ # Check the original source code, not the normalized snippet
98
+ if self._is_single_statement_in_source(content, start_line, end_line):
99
+ continue
100
100
 
101
- # Apply extensible filters (keyword arguments, imports, etc.)
102
- if self._filter_registry.should_filter_block(block, content):
103
- continue
101
+ block = CodeBlock(
102
+ file_path=file_path,
103
+ start_line=start_line,
104
+ end_line=end_line,
105
+ snippet=snippet,
106
+ hash_value=hash_val,
107
+ )
108
+
109
+ # Apply extensible filters (keyword arguments, imports, etc.)
110
+ if self._filter_registry.should_filter_block(block, content):
111
+ continue
104
112
 
105
- blocks.append(block)
113
+ blocks.append(block)
106
114
 
107
- return blocks
115
+ return blocks
116
+ finally:
117
+ # Clear cache after analysis to avoid memory leaks
118
+ self._cached_ast = None
119
+ self._cached_content = None
108
120
 
109
121
  def _get_docstring_ranges_from_content(self, content: str) -> set[int]:
110
122
  """Extract line numbers that are part of docstrings.
@@ -225,10 +237,19 @@ class PythonDuplicateAnalyzer(BaseTokenAnalyzer): # thailint: ignore[srp.violat
225
237
  return hashes
226
238
 
227
239
  def _is_single_statement_in_source(self, content: str, start_line: int, end_line: int) -> bool:
228
- """Check if a line range in the original source is a single logical statement."""
229
- tree = self._parse_content_safe(content)
230
- if tree is None:
231
- return False
240
+ """Check if a line range in the original source is a single logical statement.
241
+
242
+ Performance optimization: Uses cached AST if available (set by analyze() method)
243
+ to avoid re-parsing the entire file for each hash window check.
244
+ """
245
+ # Use cached AST if available and content matches
246
+ if self._cached_ast is not None and content == self._cached_content:
247
+ tree = self._cached_ast
248
+ else:
249
+ # Fallback: parse content (used by tests or standalone calls)
250
+ tree = self._parse_content_safe(content)
251
+ if tree is None:
252
+ return False
232
253
 
233
254
  return self._check_overlapping_nodes(tree, start_line, end_line)
234
255
 
@@ -241,9 +262,19 @@ class PythonDuplicateAnalyzer(BaseTokenAnalyzer): # thailint: ignore[srp.violat
241
262
  return None
242
263
 
243
264
  def _check_overlapping_nodes(self, tree: ast.Module, start_line: int, end_line: int) -> bool:
244
- """Check if any AST node overlaps and matches single-statement pattern."""
265
+ """Check if any AST node overlaps and matches single-statement pattern.
266
+
267
+ Performance optimization: Pre-filter nodes by line range before expensive pattern checks.
268
+ """
245
269
  for node in ast.walk(tree):
246
- if self._node_overlaps_and_matches(node, start_line, end_line):
270
+ # Quick line range check to skip nodes that don't overlap
271
+ if not hasattr(node, "lineno") or not hasattr(node, "end_lineno"):
272
+ continue
273
+ if node.end_lineno < start_line or node.lineno > end_line:
274
+ continue # No overlap, skip expensive pattern matching
275
+
276
+ # Node overlaps - check if it matches single-statement pattern
277
+ if self._is_single_statement_pattern(node, start_line, end_line):
247
278
  return True
248
279
  return False
249
280
 
src/orchestrator/core.py CHANGED
@@ -101,8 +101,9 @@ class Orchestrator:
101
101
  self.config_loader = LinterConfigLoader()
102
102
  self.ignore_parser = IgnoreDirectiveParser(self.project_root)
103
103
 
104
- # Auto-discover and register all linting rules from src.linters
105
- self.registry.discover_rules("src.linters")
104
+ # Performance optimization: Defer rule discovery until first file is linted
105
+ # This eliminates ~0.077s overhead for commands that don't need rules (--help, config, etc.)
106
+ self._rules_discovered = False
106
107
 
107
108
  # Use provided config or load from project root
108
109
  if config is not None:
@@ -208,6 +209,12 @@ class Orchestrator:
208
209
 
209
210
  return violations
210
211
 
212
+ def _ensure_rules_discovered(self) -> None:
213
+ """Ensure rules have been discovered and registered (lazy initialization)."""
214
+ if not self._rules_discovered:
215
+ self.registry.discover_rules("src.linters")
216
+ self._rules_discovered = True
217
+
211
218
  def _get_rules_for_file(self, file_path: Path, language: str) -> list[BaseLintRule]:
212
219
  """Get rules applicable to this file.
213
220
 
@@ -218,6 +225,9 @@ class Orchestrator:
218
225
  Returns:
219
226
  List of rules to execute against this file.
220
227
  """
228
+ # Lazy initialization: discover rules on first lint operation
229
+ self._ensure_rules_discovered()
230
+
221
231
  # For now, return all registered rules
222
232
  # Future: filter by language, configuration, etc.
223
233
  return self.registry.list_all()
src/utils/project_root.py CHANGED
@@ -4,29 +4,52 @@ Purpose: Centralized project root detection for consistent file placement
4
4
  Scope: Single source of truth for finding project root directory
5
5
 
6
6
  Overview: Uses pyprojroot package to provide reliable project root detection across
7
- different environments (development, CI/CD, user installations). Delegates all
8
- project root detection logic to the industry-standard pyprojroot library which
9
- handles various project markers and edge cases that we cannot anticipate.
7
+ different environments (development, CI/CD, user installations). Falls back to
8
+ manual detection if pyprojroot is not available (e.g., in test environments).
9
+ Searches for standard project markers like .git, .thailint.yaml, and pyproject.toml.
10
10
 
11
- Dependencies: pyprojroot for robust project root detection
11
+ Dependencies: pyprojroot (optional, with manual fallback)
12
12
 
13
13
  Exports: is_project_root(), get_project_root()
14
14
 
15
15
  Interfaces: Path-based functions for checking and finding project roots
16
16
 
17
- Implementation: Pure delegation to pyprojroot with fallback to start_path when no root found
17
+ Implementation: pyprojroot delegation with manual fallback for test environments
18
18
  """
19
19
 
20
20
  from pathlib import Path
21
21
 
22
- from pyprojroot import find_root
22
+ # Try to import pyprojroot, but don't fail if it's not available
23
+ try:
24
+ from pyprojroot import find_root
25
+
26
+ HAS_PYPROJROOT = True
27
+ except ImportError:
28
+ HAS_PYPROJROOT = False
29
+
30
+
31
+ def _has_marker(path: Path, marker_name: str, is_dir: bool = False) -> bool:
32
+ """Check if a directory contains a specific marker.
33
+
34
+ Args:
35
+ path: Directory path to check
36
+ marker_name: Name of marker file or directory
37
+ is_dir: True if marker is a directory, False if it's a file
38
+
39
+ Returns:
40
+ True if marker exists, False otherwise
41
+ """
42
+ marker_path = path / marker_name
43
+ if is_dir:
44
+ return marker_path.is_dir()
45
+ return marker_path.is_file()
23
46
 
24
47
 
25
48
  def is_project_root(path: Path) -> bool:
26
49
  """Check if a directory is a project root.
27
50
 
28
- Uses pyprojroot to detect if the given path is a project root by checking
29
- if finding the root from this path returns the same path.
51
+ Uses pyprojroot if available, otherwise checks for common project markers
52
+ like .git, .thailint.yaml, or pyproject.toml.
30
53
 
31
54
  Args:
32
55
  path: Directory path to check
@@ -43,6 +66,21 @@ def is_project_root(path: Path) -> bool:
43
66
  if not path.exists() or not path.is_dir():
44
67
  return False
45
68
 
69
+ if HAS_PYPROJROOT:
70
+ return _check_root_with_pyprojroot(path)
71
+
72
+ return _check_root_with_markers(path)
73
+
74
+
75
+ def _check_root_with_pyprojroot(path: Path) -> bool:
76
+ """Check if path is project root using pyprojroot.
77
+
78
+ Args:
79
+ path: Directory path to check
80
+
81
+ Returns:
82
+ True if path is a project root, False otherwise
83
+ """
46
84
  try:
47
85
  # Find root from this path - if it equals this path, it's a root
48
86
  found_root = find_root(path)
@@ -52,6 +90,22 @@ def is_project_root(path: Path) -> bool:
52
90
  return False
53
91
 
54
92
 
93
+ def _check_root_with_markers(path: Path) -> bool:
94
+ """Check if path contains project root markers.
95
+
96
+ Args:
97
+ path: Directory path to check
98
+
99
+ Returns:
100
+ True if path contains .git, .thailint.yaml, or pyproject.toml
101
+ """
102
+ return (
103
+ _has_marker(path, ".git", is_dir=True)
104
+ or _has_marker(path, ".thailint.yaml", is_dir=False)
105
+ or _has_marker(path, "pyproject.toml", is_dir=False)
106
+ )
107
+
108
+
55
109
  def _try_find_with_criterion(criterion: object, start_path: Path) -> Path | None:
56
110
  """Try to find project root with a specific criterion.
57
111
 
@@ -68,14 +122,42 @@ def _try_find_with_criterion(criterion: object, start_path: Path) -> Path | None
68
122
  return None
69
123
 
70
124
 
125
+ def _find_root_manual(start_path: Path) -> Path:
126
+ """Manually find project root by walking up directory tree.
127
+
128
+ Fallback implementation when pyprojroot is not available.
129
+
130
+ Args:
131
+ start_path: Directory to start searching from
132
+
133
+ Returns:
134
+ Path to project root, or start_path if no markers found
135
+ """
136
+ current = start_path.resolve()
137
+
138
+ # Walk up the directory tree
139
+ for parent in [current] + list(current.parents):
140
+ # Check for project markers
141
+ if (
142
+ _has_marker(parent, ".git", is_dir=True)
143
+ or _has_marker(parent, ".thailint.yaml", is_dir=False)
144
+ or _has_marker(parent, "pyproject.toml", is_dir=False)
145
+ ):
146
+ return parent
147
+
148
+ # No markers found, return start path
149
+ return current
150
+
151
+
71
152
  def get_project_root(start_path: Path | None = None) -> Path:
72
153
  """Find project root by walking up the directory tree.
73
154
 
74
155
  This is the single source of truth for project root detection.
75
156
  All code that needs to find the project root should use this function.
76
157
 
77
- Uses pyprojroot which searches for standard project markers (.git directory,
78
- pyproject.toml, .thailint.yaml, etc) starting from start_path and walking upward.
158
+ Uses pyprojroot if available, otherwise uses manual detection searching for
159
+ standard project markers (.git directory, pyproject.toml, .thailint.yaml, etc)
160
+ starting from start_path and walking upward.
79
161
 
80
162
  Args:
81
163
  start_path: Directory to start searching from. If None, uses current working directory.
@@ -87,13 +169,29 @@ def get_project_root(start_path: Path | None = None) -> Path:
87
169
  >>> root = get_project_root()
88
170
  >>> config_file = root / ".thailint.yaml"
89
171
  """
90
- from pyprojroot import has_dir, has_file
91
-
92
172
  if start_path is None:
93
173
  start_path = Path.cwd()
94
174
 
95
175
  current = start_path.resolve()
96
176
 
177
+ if HAS_PYPROJROOT:
178
+ return _find_root_with_pyprojroot(current)
179
+
180
+ # Manual fallback for test environments
181
+ return _find_root_manual(current)
182
+
183
+
184
+ def _find_root_with_pyprojroot(current: Path) -> Path:
185
+ """Find project root using pyprojroot library.
186
+
187
+ Args:
188
+ current: Current path to start searching from
189
+
190
+ Returns:
191
+ Path to project root, or current if no markers found
192
+ """
193
+ from pyprojroot import has_dir, has_file
194
+
97
195
  # Search for project root markers in priority order
98
196
  # Try .git first (most reliable), then .thailint.yaml, then pyproject.toml
99
197
  for criterion in [has_dir(".git"), has_file(".thailint.yaml"), has_file("pyproject.toml")]:
@@ -1,8 +1,9 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: thailint
3
- Version: 0.4.2
3
+ Version: 0.4.4
4
4
  Summary: The AI Linter - Enterprise-grade linting and governance for AI-generated code across multiple languages
5
5
  License: MIT
6
+ License-File: LICENSE
6
7
  Keywords: linter,ai,code-quality,static-analysis,file-placement,governance,multi-language,cli,docker,python
7
8
  Author: Steve Jackson
8
9
  Requires-Python: >=3.11,<4.0
@@ -15,6 +16,7 @@ Classifier: Programming Language :: Python :: 3
15
16
  Classifier: Programming Language :: Python :: 3.11
16
17
  Classifier: Programming Language :: Python :: 3.12
17
18
  Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
18
20
  Classifier: Programming Language :: Python :: 3 :: Only
19
21
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
22
  Classifier: Topic :: Software Development :: Quality Assurance
@@ -35,7 +37,7 @@ Description-Content-Type: text/markdown
35
37
 
36
38
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
37
39
  [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
38
- [![Tests](https://img.shields.io/badge/tests-267%2F267%20passing-brightgreen.svg)](tests/)
40
+ [![Tests](https://img.shields.io/badge/tests-296%2F296%20passing-brightgreen.svg)](tests/)
39
41
  [![Coverage](https://img.shields.io/badge/coverage-87%25-brightgreen.svg)](htmlcov/)
40
42
 
41
43
  The AI Linter - Enterprise-ready linting and governance for AI-generated code across multiple languages.
@@ -201,6 +203,44 @@ docker run --rm -v $(pwd):/data \
201
203
  washad/thailint:latest nesting /data/src
202
204
  ```
203
205
 
206
+ ### Docker with Sibling Directories
207
+
208
+ For Docker environments with sibling directories (e.g., separate config and source directories), use `--project-root` or config path inference:
209
+
210
+ ```bash
211
+ # Directory structure:
212
+ # /workspace/
213
+ # ├── root/ # Contains .thailint.yaml and .git
214
+ # ├── backend/ # Code to lint
215
+ # └── tools/
216
+
217
+ # Option 1: Explicit project root (recommended)
218
+ docker run --rm -v $(pwd):/data \
219
+ washad/thailint:latest \
220
+ --project-root /data/root \
221
+ magic-numbers /data/backend/
222
+
223
+ # Option 2: Config path inference (automatic)
224
+ docker run --rm -v $(pwd):/data \
225
+ washad/thailint:latest \
226
+ --config /data/root/.thailint.yaml \
227
+ magic-numbers /data/backend/
228
+
229
+ # With ignore patterns resolving from project root
230
+ docker run --rm -v $(pwd):/data \
231
+ washad/thailint:latest \
232
+ --project-root /data/root \
233
+ --config /data/root/.thailint.yaml \
234
+ magic-numbers /data/backend/
235
+ ```
236
+
237
+ **Priority order:**
238
+ 1. `--project-root` (highest priority - explicit specification)
239
+ 2. Inferred from `--config` path directory
240
+ 3. Auto-detection from file location (fallback)
241
+
242
+ See **[Docker Usage](#docker-usage)** section below for more examples.
243
+
204
244
  ## Configuration
205
245
 
206
246
  Create `.thailint.yaml` in your project root:
@@ -1033,6 +1073,8 @@ See `just --list` or `just help` for all available commands.
1033
1073
 
1034
1074
  ## Docker Usage
1035
1075
 
1076
+ ### Basic Docker Commands
1077
+
1036
1078
  ```bash
1037
1079
  # Pull published image
1038
1080
  docker pull washad/thailint:latest
@@ -1061,6 +1103,44 @@ docker run --rm -v $(pwd):/data \
1061
1103
  washad/thailint:latest file-placement --format json /data
1062
1104
  ```
1063
1105
 
1106
+ ### Docker with Sibling Directories (Advanced)
1107
+
1108
+ For complex Docker setups with sibling directories, use `--project-root` for explicit control:
1109
+
1110
+ ```bash
1111
+ # Scenario: Monorepo with separate config and code directories
1112
+ # Directory structure:
1113
+ # /workspace/
1114
+ # ├── config/ # Contains .thailint.yaml
1115
+ # ├── backend/app/ # Python backend code
1116
+ # ├── frontend/ # TypeScript frontend
1117
+ # └── tools/ # Build tools
1118
+
1119
+ # Explicit project root (recommended for Docker)
1120
+ docker run --rm -v /path/to/workspace:/workspace \
1121
+ washad/thailint:latest \
1122
+ --project-root /workspace/config \
1123
+ magic-numbers /workspace/backend/
1124
+
1125
+ # Config path inference (automatic - no --project-root needed)
1126
+ docker run --rm -v /path/to/workspace:/workspace \
1127
+ washad/thailint:latest \
1128
+ --config /workspace/config/.thailint.yaml \
1129
+ magic-numbers /workspace/backend/
1130
+
1131
+ # Lint multiple sibling directories with shared config
1132
+ docker run --rm -v /path/to/workspace:/workspace \
1133
+ washad/thailint:latest \
1134
+ --project-root /workspace/config \
1135
+ nesting /workspace/backend/ /workspace/frontend/
1136
+ ```
1137
+
1138
+ **When to use `--project-root` in Docker:**
1139
+ - **Sibling directory structures** - When config/code aren't nested
1140
+ - **Monorepos** - Multiple projects sharing one config
1141
+ - **CI/CD** - Explicit paths prevent auto-detection issues
1142
+ - **Ignore patterns** - Ensures patterns resolve from correct base directory
1143
+
1064
1144
  ## Documentation
1065
1145
 
1066
1146
  ### Comprehensive Guides
@@ -2,7 +2,7 @@ src/__init__.py,sha256=f601zncODr2twrUHqTLS5wyOdZqZi9tMjAe2INhRKqU,2175
2
2
  src/analyzers/__init__.py,sha256=fFloZtjkBGwYbAhKTxS3Qy3yDr2_3i3WSfKTw1mAioo,972
3
3
  src/analyzers/typescript_base.py,sha256=4I7fAcMOAY9vY1AXh52QpohgFmguBECwOkvBRP4zCS4,5054
4
4
  src/api.py,sha256=pJ5l3qxccKBEY-BkANwzTgLAl1ZFq7OP6hx6LSxbhDw,4664
5
- src/cli.py,sha256=54g24wpF1tzyJW6gflZoIDYfeewjP123no3XliJdVsQ,40544
5
+ src/cli.py,sha256=VvMTaltsFKtCE4DNu6HqnSgKyZYulq3qALO8uteafI8,48092
6
6
  src/config.py,sha256=2ebAjIpAhw4bHbOxViEA5nCjfBlDEIrMR59DBrzcYzM,12460
7
7
  src/core/__init__.py,sha256=5FtsDvhMt4SNRx3pbcGURrxn135XRbeRrjSUxiXwkNc,381
8
8
  src/core/base.py,sha256=Eklcagi2ktfY4Kytl_ObXov2U49N9OGDpw4cu4PUzGY,7824
@@ -30,7 +30,7 @@ src/linters/dry/duplicate_storage.py,sha256=3OxE2mtoWGAsNNrB8J2c-4JirLUoqZ9ptydO
30
30
  src/linters/dry/file_analyzer.py,sha256=ufSQ85ddsGTqGnBHZNTdV_5DGfTpUmJOB58sIdJNV0I,2928
31
31
  src/linters/dry/inline_ignore.py,sha256=ASfA-fp_1aPpkakN2e0T6qdTh8S7Jqj89ovxXJLmFlc,4439
32
32
  src/linters/dry/linter.py,sha256=XMLwCgGrFX0l0dVUJs1jpsXOfgxeKKDbxOtN5h5Emhk,5835
33
- src/linters/dry/python_analyzer.py,sha256=Qj9dElypv8K3Qno20F5JfuOm7IKGvDyuGSvb4032n7Q,21140
33
+ src/linters/dry/python_analyzer.py,sha256=RoC_OD0UqI0j5HVEwSZWUZVyHDNUtywvxse0HRumLoI,22748
34
34
  src/linters/dry/storage_initializer.py,sha256=ykMALFs4uMUrN0_skEwySDl_t5Dm_LGHllF0OxDhiUI,1366
35
35
  src/linters/dry/token_hasher.py,sha256=mCFuP0FQFALyKghBgZHcspsoOxgT7C7ZkfspnhFA5U4,3609
36
36
  src/linters/dry/typescript_analyzer.py,sha256=n1rsQYp7nuPhgErbG8hWawkywRz-iFGhrGlQXDrIa14,21494
@@ -71,13 +71,13 @@ src/linters/srp/typescript_analyzer.py,sha256=Wi0P_G1v5AnZYtMN3sNm1iHva84-8Kep2L
71
71
  src/linters/srp/typescript_metrics_calculator.py,sha256=2VLRux_tf1Cw645wwTuol3Z5A6-mkl4cgyW34myy00Q,2728
72
72
  src/linters/srp/violation_builder.py,sha256=jaIjVtRYWUTs1SVJVwd0FxCojo0DxhPzfhyfMKmAroM,3881
73
73
  src/orchestrator/__init__.py,sha256=XXLDJq2oaB-TpP2Y97GRnde9EkITGuFCmuLrDfxI9nY,245
74
- src/orchestrator/core.py,sha256=zb4H4HtDNLmnsRCUXI3oNtfM3T-nTPW9Q2pAbI61VEs,8374
74
+ src/orchestrator/core.py,sha256=z0YcwsK18uhlztIPi54ux3mOm8fHMREYJoudsJPhC0Q,8857
75
75
  src/orchestrator/language_detector.py,sha256=rHyVMApit80NTTNyDH1ObD1usKD8LjGmH3DwqNAWYGc,2736
76
76
  src/templates/thailint_config_template.yaml,sha256=u8WFv2coE4uqfgf_slw7xjo4kGYIowDm1RIgxsKQzrE,4275
77
77
  src/utils/__init__.py,sha256=NiBtKeQ09Y3kuUzeN4O1JNfUIYPQDS2AP1l5ODq-Dec,125
78
- src/utils/project_root.py,sha256=kGxQU1nHItffiR6iIHT_xCy9RQJtl1Y6dgSUnJHRJQ8,3498
79
- thailint-0.4.2.dist-info/LICENSE,sha256=kxh1J0Sb62XvhNJ6MZsVNe8PqNVJ7LHRn_EWa-T3djw,1070
80
- thailint-0.4.2.dist-info/METADATA,sha256=HT1yu78t_cI-2Mv3kw0Kci1o6iBAEdz3jpwJwS6dOkg,34038
81
- thailint-0.4.2.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
82
- thailint-0.4.2.dist-info/entry_points.txt,sha256=l7DQJgU18sVLDpSaXOXY3lLhnQHQIRrSJZTQjG1cEAk,62
83
- thailint-0.4.2.dist-info/RECORD,,
78
+ src/utils/project_root.py,sha256=b3YTEGTa9RPcOeHn1IByMMWyRiUabfVlpnlektL0A0o,6156
79
+ thailint-0.4.4.dist-info/METADATA,sha256=1cmCJ3Myhrt90V0qWw-gfDr6cVP6nRO5fOZmy-TGSVY,36717
80
+ thailint-0.4.4.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
81
+ thailint-0.4.4.dist-info/entry_points.txt,sha256=l7DQJgU18sVLDpSaXOXY3lLhnQHQIRrSJZTQjG1cEAk,62
82
+ thailint-0.4.4.dist-info/licenses/LICENSE,sha256=kxh1J0Sb62XvhNJ6MZsVNe8PqNVJ7LHRn_EWa-T3djw,1070
83
+ thailint-0.4.4.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 2.1.3
2
+ Generator: poetry-core 2.2.1
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any