claude-dev-env 2.35.0 → 4.0.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 (99) hide show
  1. package/.agents/skills/_shared/advisor/scripts/README.md +1 -1
  2. package/.agents/skills/autoconverge/SKILL.md +19 -15
  3. package/.agents/skills/autoconverge/reference/gotchas.md +3 -3
  4. package/.agents/skills/autoconverge/reference/multi-pr.md +6 -2
  5. package/.agents/skills/autoconverge/workflow/converge.contract.test.mjs +11 -0
  6. package/.agents/skills/autoconverge/workflow/converge.mjs +26 -33
  7. package/.agents/skills/autoconverge/workflow/converge.run-input.test.mjs +26 -2
  8. package/.agents/skills/autoconverge/workflow/converge_multi.mjs +12 -28
  9. package/.agents/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +16 -0
  10. package/.agents/skills/team-advisor/SKILL.md +11 -11
  11. package/_shared/advisor/advisor-protocol.md +51 -51
  12. package/_shared/advisor/reference/advisor-block.md +9 -9
  13. package/_shared/advisor/reference/astra-rung.md +39 -0
  14. package/_shared/advisor/reference/identity.md +4 -4
  15. package/_shared/advisor/reference/lifecycle.md +6 -6
  16. package/_shared/advisor/reference/spawn-walk-log.md +9 -9
  17. package/_shared/advisor/reference/third-party-bind.md +11 -11
  18. package/_shared/advisor/reference/warm-up.md +13 -13
  19. package/_shared/advisor/scripts/codex_astra_advisor.py +308 -0
  20. package/_shared/advisor/scripts/codex_astra_preflight.py +115 -0
  21. package/_shared/advisor/scripts/codex_astra_reply.py +159 -0
  22. package/_shared/advisor/scripts/config/advisor_scripts_constants/advisor_route_constants.py +3 -3
  23. package/_shared/advisor/scripts/config/advisor_scripts_constants/astra_advisor_constants.py +38 -0
  24. package/_shared/advisor/scripts/model_tier_run_validator.py +17 -18
  25. package/_shared/advisor/scripts/tests/test_codex_astra_advisor.py +271 -0
  26. package/_shared/advisor/scripts/tests/test_codex_astra_preflight.py +24 -0
  27. package/_shared/advisor/scripts/tests/test_codex_astra_reply.py +42 -0
  28. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +55 -30
  29. package/_shared/advisor/scripts/tests/test_tier_model_ids.py +14 -4
  30. package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +14 -0
  31. package/_shared/pr-loop/scripts/terminology_sweep.py +138 -50
  32. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +13 -1
  33. package/bin/cde.mjs +175 -0
  34. package/bin/cde.test.mjs +222 -0
  35. package/bin/install-constants.mjs +4 -0
  36. package/bin/install.cursor-rules.test.mjs +233 -2
  37. package/bin/install.mjs +49 -3
  38. package/bin/pstack-model-preferences.codex.json +23 -0
  39. package/bin/select-pstack-models.test.mjs +378 -0
  40. package/docs/references/team-advisor-skill.md +3 -3
  41. package/hooks/hooks_constants/tool_names.py +20 -0
  42. package/hooks/validators/README.md +35 -1
  43. package/hooks/validators/fast_save_validators.py +63 -89
  44. package/hooks/validators/run_all_validators.py +16 -8
  45. package/hooks/validators/test_fast_save_validators_policy_lint.py +25 -0
  46. package/hooks/validators/test_run_all_validators_policy_lint.py +52 -0
  47. package/package.json +3 -2
  48. package/rules/pstack-models.md +119 -0
  49. package/scripts/cde_lint.py +258 -0
  50. package/scripts/check.ps1 +18 -6
  51. package/scripts/policy_lint/__init__.py +41 -0
  52. package/scripts/policy_lint/adapter_configuration.py +91 -0
  53. package/scripts/policy_lint/adapter_detectors.py +368 -0
  54. package/scripts/policy_lint/adapter_pairing.py +145 -0
  55. package/scripts/policy_lint/adapter_support.py +97 -0
  56. package/scripts/policy_lint/adapters.py +376 -0
  57. package/scripts/policy_lint/config/__init__.py +1 -0
  58. package/scripts/policy_lint/config/constants.py +77 -0
  59. package/scripts/policy_lint/engine.py +224 -0
  60. package/scripts/policy_lint/model.py +388 -0
  61. package/scripts/policy_lint/registry.py +171 -0
  62. package/scripts/policy_lint/render.py +91 -0
  63. package/scripts/policy_lint/selection.py +372 -0
  64. package/scripts/policy_lint/selection_git.py +147 -0
  65. package/scripts/repository_checks/__init__.py +1 -0
  66. package/scripts/repository_checks/claude_md.py +108 -0
  67. package/scripts/repository_checks/config/__init__.py +1 -0
  68. package/scripts/repository_checks/config/constants.py +150 -0
  69. package/scripts/repository_checks/env_var_documentation.py +243 -0
  70. package/scripts/repository_checks/hook_modules.py +34 -0
  71. package/scripts/repository_checks/models.py +37 -0
  72. package/scripts/repository_checks/package_inventory.py +118 -0
  73. package/scripts/repository_checks/pytest_testpaths.py +84 -0
  74. package/scripts/repository_checks/reporting.py +39 -0
  75. package/scripts/repository_checks/runner.py +93 -0
  76. package/scripts/repository_checks/tracked_secrets.py +147 -0
  77. package/scripts/repository_checks/tracked_tree.py +28 -0
  78. package/scripts/repository_policy.py +120 -0
  79. package/scripts/select_pstack_models.mjs +304 -0
  80. package/scripts/tests/repository_policy_test_support.py +89 -0
  81. package/scripts/tests/test_cde_lint.py +398 -0
  82. package/scripts/tests/test_claude_md.py +68 -0
  83. package/scripts/tests/test_env_var_documentation.py +133 -0
  84. package/scripts/tests/test_hook_modules.py +17 -0
  85. package/scripts/tests/test_models.py +26 -0
  86. package/scripts/tests/test_package_inventory.py +96 -0
  87. package/scripts/tests/test_policy_lint_rules_engine.py +381 -0
  88. package/scripts/tests/test_policy_lint_rules_registry.py +369 -0
  89. package/scripts/tests/test_policy_lint_selection.py +373 -0
  90. package/scripts/tests/test_pytest_testpaths.py +66 -0
  91. package/scripts/tests/test_reporting.py +54 -0
  92. package/scripts/tests/test_repository_policy.py +71 -0
  93. package/scripts/tests/test_runner.py +59 -0
  94. package/scripts/tests/test_tracked_secrets.py +175 -0
  95. package/scripts/tests/test_tracked_tree.py +28 -0
  96. package/_shared/advisor/reference/sol-rung.md +0 -39
  97. package/_shared/advisor/scripts/codex_sol_advisor.py +0 -581
  98. package/_shared/advisor/scripts/config/advisor_scripts_constants/sol_advisor_constants.py +0 -38
  99. package/_shared/advisor/scripts/tests/test_codex_sol_advisor.py +0 -740
@@ -1,7 +1,7 @@
1
1
  """Flag a prose term that names a code identifier a hair differently.
2
2
 
3
3
  A change adds the field ``premium_request_interactions`` while the same
4
- branch's docs call it the ``premium-request-budget`` field: one thing, two
4
+ branch's docs call it the ``premium-request-budget`` field. One thing, two
5
5
  names, and a reader who searches one never finds the other.
6
6
 
7
7
  ::
@@ -14,38 +14,29 @@ names, and a reader who searches one never finds the other.
14
14
 
15
15
  The sweep reads a unified diff, collects the multi-word identifiers added on
16
16
  code lines, and scans each added prose line for a hyphen or space variant that
17
- renames only the final word of an identifier: it agrees on every earlier word
18
- and differs on the last. A term that diverges in any earlier word, or shares
19
- only one leading word, is ordinary prose. It also spares English compound
20
- tails, singular/plural forms, stopword windows, and words that are themselves
21
- tokens of another introduced identifier.
17
+ renames only the final word of an identifier. The variant agrees on every
18
+ earlier word and differs on the last. A term that diverges in any earlier
19
+ word, or shares only one leading word, is ordinary prose. It also spares
20
+ English compound tails, singular/plural forms, stopword windows, and words
21
+ that are themselves tokens of another introduced identifier.
22
22
  """
23
23
 
24
24
  import argparse
25
25
  import os
26
26
  import subprocess
27
27
  import sys
28
+ from collections.abc import Sequence
28
29
  from pathlib import Path
29
30
 
30
- parent_directory = str(Path(__file__).resolve().parent)
31
- if parent_directory not in sys.path:
32
- sys.path.insert(0, parent_directory)
33
-
34
- from pr_loop_shared_constants.terminology_sweep_constants import ( # noqa: E402
31
+ from pr_loop_shared_constants.terminology_sweep_constants import (
35
32
  ALL_COMMON_ENGLISH_COMPOUND_TAIL_WORDS,
33
+ ALL_DIFF_FILE_PATH_STRIP_PREFIXES,
34
+ ALL_GIT_DIFF_CACHED_UNIFIED_ZERO_COMMAND,
36
35
  ALL_GIT_GREP_BASE_TREE_COMMAND_PREFIX,
37
36
  ALL_PROSE_STOPWORD_TOKENS,
38
37
  ALL_STRING_ESCAPE_SEQUENCES,
39
- ALL_TEST_FILE_NAME_INFIX_MARKERS,
40
- GIT_BASE_TREE_REVISION,
41
- IDENTIFIER_TOKEN_SEPARATOR,
42
- PROSE_WINDOW_WORD_SEPARATOR,
43
- TEST_DIRECTORY_PATH_SEGMENT,
44
- TEST_FILE_PREFIX,
45
- TEST_FILE_SUFFIX,
46
- ALL_DIFF_FILE_PATH_STRIP_PREFIXES,
47
- ALL_GIT_DIFF_CACHED_UNIFIED_ZERO_COMMAND,
48
38
  ALL_SWEEP_CODE_FILE_EXTENSIONS,
39
+ ALL_TEST_FILE_NAME_INFIX_MARKERS,
49
40
  CAMEL_CASE_IDENTIFIER_PATTERN,
50
41
  CAMEL_CASE_WORD_PATTERN,
51
42
  DIFF_ADDED_LINE_PREFIX,
@@ -55,19 +46,32 @@ from pr_loop_shared_constants.terminology_sweep_constants import ( # noqa: E402
55
46
  DIFF_NEW_FILE_HEADER_PREFIX,
56
47
  DIFF_OLD_FILE_HEADER_PREFIX,
57
48
  DIFF_REMOVED_LINE_PREFIX,
49
+ GIT_BASE_TREE_REVISION,
50
+ GIT_DECODE_ERRORS,
51
+ GIT_DIFF_OPERATION,
58
52
  GIT_DIFF_SUBPROCESS_TIMEOUT_SECONDS,
53
+ GIT_FAILURE_WITHOUT_DETAILS,
54
+ GIT_GREP_OPERATION_TEMPLATE,
55
+ GIT_NO_MATCH_EXIT_CODE,
56
+ GIT_OPERATION_FAILURE_TEMPLATE,
57
+ GIT_TEXT_ENCODING,
59
58
  HYPHENATED_PROSE_TOKEN_PATTERN,
59
+ IDENTIFIER_TOKEN_SEPARATOR,
60
60
  INLINE_CODE_SPAN_PATTERN,
61
61
  JAVASCRIPT_LINE_COMMENT_MARKER,
62
62
  JSDOC_CONTINUATION_MARKER,
63
63
  MARKDOWN_FILE_EXTENSION,
64
64
  MINIMUM_IDENTIFIER_TOKEN_COUNT,
65
+ PROSE_WINDOW_WORD_SEPARATOR,
65
66
  PROSE_WORD_PATTERN,
66
67
  PYTHON_COMMENT_MARKER,
67
68
  SNAKE_CASE_IDENTIFIER_PATTERN,
68
69
  STRING_LITERAL_CONTENT_PATTERN,
69
70
  TERMINOLOGY_FINDING_TEMPLATE,
70
71
  TERMINOLOGY_SWEEP_DESCRIPTION,
72
+ TEST_DIRECTORY_PATH_SEGMENT,
73
+ TEST_FILE_PREFIX,
74
+ TEST_FILE_SUFFIX,
71
75
  )
72
76
 
73
77
  IdentifierTuple = tuple[str, ...]
@@ -173,8 +177,8 @@ def _prose_fragments(file_path: str, line_text: str) -> list[str]:
173
177
  A Markdown line is prose with its inline-code spans removed, since a
174
178
  backticked span names code verbatim. A code line contributes its comment
175
179
  tail, its JSDoc continuation text, and the contents of its string
176
- literals. A test module contributes its comment tail and JSDoc text only
177
- — its string literals hold fixture data, not prose.
180
+ literals. A test module contributes its comment tail and JSDoc text only.
181
+ Its string literals hold fixture data, not prose.
178
182
 
179
183
  Args:
180
184
  file_path: The path the added line belongs to.
@@ -201,9 +205,9 @@ def _prose_fragments(file_path: str, line_text: str) -> list[str]:
201
205
  def _is_test_file(file_path: str) -> bool:
202
206
  """Return whether a diff path names a test code module.
203
207
 
204
- A test module's string literals hold fixture data — embedded diffs,
205
- generated source, file trees — not documentation prose, so the sweep
206
- reads only its comments and JSDoc lines. A ``quota.test.mjs``, a
208
+ A test module's string literals hold fixture data: embedded diffs,
209
+ generated source, and file trees. That is not documentation prose, so the
210
+ sweep reads only its comments and JSDoc lines. A ``quota.test.mjs``, a
207
211
  ``layout.spec.ts``, and a ``fixtures.py`` under a tests directory are all
208
212
  test modules, so every one of these paths counts::
209
213
 
@@ -260,8 +264,8 @@ def _string_literal_fragments(line_text: str) -> list[str]:
260
264
  Escape sequences inside a literal (``\\n``, ``\\t``) are replaced with
261
265
  spaces before the words are read, so an escape letter never glues onto a
262
266
  neighbouring word as a phantom prose term. A literal left with no
263
- whitespace after that replacement is an identifier-shaped value — a UID,
264
- a key, a path — not prose, and contributes nothing.
267
+ whitespace after that replacement is an identifier-shaped value: a UID,
268
+ a key, or a path. It is not prose, and contributes nothing.
265
269
  """
266
270
  all_fragments: list[str] = []
267
271
  for each_match in STRING_LITERAL_CONTENT_PATTERN.finditer(line_text):
@@ -413,9 +417,7 @@ def _word_is_identifier_vocabulary(
413
417
  return True
414
418
  if prose_word.endswith("es") and prose_word[:-2] in all_identifier_tokens:
415
419
  return True
416
- if prose_word.endswith("ies") and prose_word[:-3] + "y" in all_identifier_tokens:
417
- return True
418
- return False
420
+ return prose_word.endswith("ies") and prose_word[:-3] + "y" in all_identifier_tokens
419
421
 
420
422
 
421
423
  def _near_miss_identifier(
@@ -525,22 +527,8 @@ def _findings_for_line(
525
527
  return all_findings
526
528
 
527
529
 
528
- def sweep_diff(
529
- diff_text: str,
530
- all_preexisting_identifier_tuples: frozenset[IdentifierTuple] = frozenset(),
531
- ) -> list[str]:
532
- """Return every terminology near-miss finding for a unified diff.
533
-
534
- Args:
535
- diff_text: The unified-diff text to sweep.
536
- all_preexisting_identifier_tuples: Token tuples of added-line identifiers
537
- the base tree already names. A pre-existing identifier is not one
538
- the diff introduces, so no prose is flagged against it. Its
539
- tokens still count as code vocabulary.
540
-
541
- Returns:
542
- One finding string per near-miss term on an added prose line.
543
- """
530
+ def _find_terminology_near_misses(
531
+ diff_text: str, all_preexisting_identifier_tuples: frozenset[IdentifierTuple]) -> list[str]:
544
532
  all_added_lines = _parse_added_lines(diff_text)
545
533
  all_identifier_tuples = _collect_introduced_identifiers(all_added_lines)
546
534
  all_identifier_tokens = frozenset(
@@ -571,6 +559,27 @@ def sweep_diff(
571
559
  return all_findings
572
560
 
573
561
 
562
+ def sweep_diff(
563
+ diff_text: str,
564
+ all_preexisting_identifier_tuples: frozenset[IdentifierTuple] = frozenset(),
565
+ ) -> list[str]:
566
+ """Return every terminology near-miss finding for a unified diff.
567
+
568
+ Args:
569
+ diff_text: The unified-diff text to sweep.
570
+ all_preexisting_identifier_tuples: Token tuples of added-line identifiers
571
+ the base tree already names. A pre-existing identifier is not one
572
+ the diff introduces, so no prose is flagged against it. Its
573
+ tokens still count as code vocabulary.
574
+
575
+ Returns:
576
+ One finding string per near-miss term on an added prose line.
577
+ """
578
+ return _find_terminology_near_misses(
579
+ diff_text, all_preexisting_identifier_tuples
580
+ )
581
+
582
+
574
583
  def repository_environment() -> dict[str, str]:
575
584
  """Return the process environment with every GIT_ variable removed.
576
585
 
@@ -619,8 +628,8 @@ def _base_tree_names(
619
628
 
620
629
  Each name is looked up as a whole word in the base revision's tree. A
621
630
  repository with no commits yet, or a lookup that git cannot run, reports
622
- the name as absent, so the sweep then treats it as newly introduced —
623
- the behaviour the sweep has with no base-tree check at all.
631
+ the name as absent, so the sweep then treats it as newly introduced.
632
+ That is the behaviour the sweep has with no base-tree check at all.
624
633
 
625
634
  Args:
626
635
  repository_root: The repository root the lookups run in.
@@ -652,11 +661,90 @@ def _base_tree_names(
652
661
  return frozenset(all_present_names)
653
662
 
654
663
 
664
+ def _run_strict_git(
665
+ repository_root: Path, all_arguments: Sequence[str]
666
+ ) -> subprocess.CompletedProcess[str]:
667
+ try:
668
+ return subprocess.run(
669
+ list(all_arguments),
670
+ cwd=str(repository_root),
671
+ capture_output=True,
672
+ text=True,
673
+ encoding=GIT_TEXT_ENCODING,
674
+ errors=GIT_DECODE_ERRORS,
675
+ timeout=GIT_DIFF_SUBPROCESS_TIMEOUT_SECONDS,
676
+ check=False,
677
+ env=repository_environment(),
678
+ )
679
+ except (subprocess.TimeoutExpired, OSError) as e:
680
+ raise RuntimeError(str(e)) from e
681
+
682
+
683
+ def _raise_for_git_failure(
684
+ git_process: subprocess.CompletedProcess[str], operation: str
685
+ ) -> None:
686
+ if git_process.returncode == 0:
687
+ return
688
+ details = git_process.stderr.strip() or git_process.stdout.strip()
689
+ raise RuntimeError(
690
+ GIT_OPERATION_FAILURE_TEMPLATE.format(
691
+ operation=operation,
692
+ details=details or GIT_FAILURE_WITHOUT_DETAILS,
693
+ )
694
+ )
695
+
696
+
697
+ def _strict_base_tree_names(
698
+ repository_root: Path, all_names: frozenset[str]
699
+ ) -> frozenset[str]:
700
+ all_present_names: set[str] = set()
701
+ for each_name in sorted(all_names):
702
+ all_arguments = (
703
+ *ALL_GIT_GREP_BASE_TREE_COMMAND_PREFIX,
704
+ each_name,
705
+ GIT_BASE_TREE_REVISION,
706
+ )
707
+ grep_process = _run_strict_git(repository_root, all_arguments)
708
+ if grep_process.returncode == 0:
709
+ all_present_names.add(each_name)
710
+ continue
711
+ if grep_process.returncode == GIT_NO_MATCH_EXIT_CODE:
712
+ continue
713
+ operation = GIT_GREP_OPERATION_TEMPLATE.format(identifier=each_name)
714
+ _raise_for_git_failure(grep_process, operation)
715
+ return frozenset(all_present_names)
716
+
717
+
718
+ def strict_staged_terminology_findings(repository_root: Path) -> list[str]:
719
+ """Return near-miss findings for the staged diff.
720
+
721
+ Args:
722
+ repository_root: The repository root the staged diff is read from.
723
+
724
+ Returns:
725
+ One finding string per near-miss term on a staged prose line.
726
+
727
+ Raises:
728
+ RuntimeError: Git could not read the staged diff or look up an
729
+ identifier in HEAD.
730
+ """
731
+ diff_process = _run_strict_git(
732
+ repository_root, ALL_GIT_DIFF_CACHED_UNIFIED_ZERO_COMMAND
733
+ )
734
+ _raise_for_git_failure(diff_process, GIT_DIFF_OPERATION)
735
+ all_added_names = _identifier_names_on_added_code_lines(diff_process.stdout)
736
+ preexisting_tuples = frozenset(
737
+ _identifier_token_tuple(each_name)
738
+ for each_name in _strict_base_tree_names(repository_root, all_added_names)
739
+ )
740
+ return _find_terminology_near_misses(diff_process.stdout, preexisting_tuples)
741
+
742
+
655
743
  def staged_terminology_findings(repository_root: Path) -> list[str]:
656
744
  """Return terminology near-miss findings for a repository's staged diff.
657
745
 
658
746
  An identifier the base tree already names is not one the staged diff
659
- introduces, so no prose is flagged against it — only genuinely new
747
+ introduces, so no prose is flagged against it. Only genuinely new
660
748
  identifiers are swept.
661
749
 
662
750
  Args:
@@ -684,7 +772,7 @@ def staged_terminology_findings(repository_root: Path) -> list[str]:
684
772
  _identifier_token_tuple(each_name)
685
773
  for each_name in _base_tree_names(repository_root, all_added_names)
686
774
  )
687
- return sweep_diff(diff_process.stdout, preexisting_tuples)
775
+ return _find_terminology_near_misses(diff_process.stdout, preexisting_tuples)
688
776
 
689
777
 
690
778
  def _read_diff_text(diff_file_path: str | None) -> str:
@@ -2,6 +2,7 @@
2
2
 
3
3
  import importlib.util
4
4
  import subprocess
5
+ import sys
5
6
  from pathlib import Path
6
7
  from types import ModuleType
7
8
 
@@ -10,17 +11,26 @@ import pytest
10
11
 
11
12
  def _load_sweep_module() -> ModuleType:
12
13
  module_path = Path(__file__).parent.parent / "terminology_sweep.py"
14
+ module_directory = str(module_path.parent)
15
+ should_remove_module_directory = module_directory not in sys.path
16
+ if should_remove_module_directory:
17
+ sys.path.insert(0, module_directory)
13
18
  spec = importlib.util.spec_from_file_location("terminology_sweep", module_path)
14
19
  assert spec is not None
15
20
  assert spec.loader is not None
16
21
  module = importlib.util.module_from_spec(spec)
17
- spec.loader.exec_module(module)
22
+ try:
23
+ spec.loader.exec_module(module)
24
+ finally:
25
+ if should_remove_module_directory:
26
+ sys.path.remove(module_directory)
18
27
  return module
19
28
 
20
29
 
21
30
  sweep_module = _load_sweep_module()
22
31
  sweep_diff = sweep_module.sweep_diff
23
32
  staged_terminology_findings = sweep_module.staged_terminology_findings
33
+ strict_staged_terminology_findings = sweep_module.strict_staged_terminology_findings
24
34
  main = sweep_module.main
25
35
  parse_added_lines = sweep_module._parse_added_lines
26
36
 
@@ -282,6 +292,8 @@ def test_staged_terminology_findings_flags_staged_prose(tmp_path: Path) -> None:
282
292
  env=_hermetic_git_environment(),
283
293
  )
284
294
  findings = staged_terminology_findings(tmp_path)
295
+ with pytest.raises(RuntimeError, match="git grep"):
296
+ strict_staged_terminology_findings(tmp_path)
285
297
  assert any("premium-request" in each_finding for each_finding in findings)
286
298
 
287
299
 
package/bin/cde.mjs ADDED
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env node
2
+ import { spawn, spawnSync } from "node:child_process";
3
+ import { constants as osConstants } from "node:os";
4
+ import { fileURLToPath } from "node:url";
5
+ import path from "node:path";
6
+
7
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
8
+ const lintScriptPath = path.join(packageRoot, "scripts", "cde_lint.py");
9
+ const posixSignalStatusOffset = 128;
10
+ const lintCommandName = "lint";
11
+ const pythonOptionName = "--python";
12
+ const pythonEnvironmentName = "CDE_PYTHON";
13
+
14
+
15
+ export function createHelpText() {
16
+ return [
17
+ "Usage: cde lint [options]",
18
+ "",
19
+ "Run the policy linter. Source modes are mutually exclusive:",
20
+ " --files PATH [PATH ...] Current worktree files",
21
+ " --staged Staged index content",
22
+ " --base REVISION Changes from a base revision",
23
+ " --repository Tracked worktree files",
24
+ " --text-as PATH One editor buffer from standard input",
25
+ "",
26
+ "Report formats: --format text|json|editor",
27
+ "",
28
+ "Launcher options:",
29
+ " --python <command> Python interpreter override",
30
+ " Exit 0 clean, 1 diagnostics, 2 invalid input or start failure, 3 failed rule",
31
+ "",
32
+ "Python may also be selected with CDE_PYTHON.",
33
+ ].join("\n");
34
+ }
35
+
36
+
37
+ export function pythonInterpreterNames(platformName = process.platform) {
38
+ if (platformName === "win32") return ["py", "python"];
39
+ return ["python3", "python"];
40
+ }
41
+
42
+
43
+ export function findPython(
44
+ commandNames = pythonInterpreterNames(),
45
+ canRun = (commandName) => spawnSync(commandName, ["--version"], { stdio: "ignore", shell: false }).status === 0,
46
+ ) {
47
+ for (const eachCommand of commandNames) {
48
+ if (canRun(eachCommand)) return eachCommand;
49
+ }
50
+ return undefined;
51
+ }
52
+
53
+
54
+ export function buildLintCommand(interpreter, forwardedArguments) {
55
+ return {
56
+ executable: interpreter,
57
+ arguments: [lintScriptPath, ...forwardedArguments],
58
+ };
59
+ }
60
+
61
+
62
+ function takePythonOverride(allArguments) {
63
+ const remainingArguments = [];
64
+ let interpreterOverride;
65
+ for (let index = 0; index < allArguments.length; index += 1) {
66
+ if (allArguments[index] === pythonOptionName) {
67
+ const nextArgument = allArguments[index + 1];
68
+ if (nextArgument === undefined || nextArgument.startsWith("--")) {
69
+ return { error: "--python requires an interpreter command" };
70
+ }
71
+ interpreterOverride = nextArgument;
72
+ index += 1;
73
+ continue;
74
+ }
75
+ remainingArguments.push(allArguments[index]);
76
+ }
77
+ return { interpreterOverride, remainingArguments, error: undefined };
78
+ }
79
+
80
+
81
+ function attachSignalForwarding(childProcess, processEmitter) {
82
+ const allSignals = ["SIGINT", "SIGTERM"];
83
+ if (process.platform !== "win32") {
84
+ allSignals.push("SIGHUP");
85
+ }
86
+ const listenerBySignal = [];
87
+ for (const eachSignal of allSignals) {
88
+ const listener = () => {
89
+ try {
90
+ childProcess.kill(eachSignal);
91
+ } catch {
92
+ return;
93
+ }
94
+ };
95
+ processEmitter.on(eachSignal, listener);
96
+ listenerBySignal.push([eachSignal, listener]);
97
+ }
98
+ return () => {
99
+ for (const [eachSignal, listener] of listenerBySignal) {
100
+ processEmitter.off(eachSignal, listener);
101
+ }
102
+ };
103
+ }
104
+
105
+
106
+ function statusCodeForSignal(signalName) {
107
+ const signalNumber = osConstants.signals?.[signalName];
108
+ if (signalNumber === undefined) return 1;
109
+ return posixSignalStatusOffset + signalNumber;
110
+ }
111
+
112
+
113
+ export function runLintCommand(command, dependencies = {}) {
114
+ const runChild = dependencies.runChild ?? spawn;
115
+ const processEmitter = dependencies.processEmitter ?? process;
116
+ return new Promise((resolve) => {
117
+ let childProcess;
118
+ try {
119
+ childProcess = runChild(command.executable, command.arguments, {
120
+ stdio: "inherit",
121
+ shell: false,
122
+ windowsHide: true,
123
+ });
124
+ } catch {
125
+ processEmitter.stderr.write("Unable to start the policy linter.\n");
126
+ resolve(2);
127
+ return;
128
+ }
129
+ const detachSignals = attachSignalForwarding(childProcess, processEmitter);
130
+ childProcess.on("error", () => {
131
+ detachSignals();
132
+ processEmitter.stderr.write("Unable to start the policy linter.\n");
133
+ resolve(2);
134
+ });
135
+ childProcess.on("close", (exitCode, signalName) => {
136
+ detachSignals();
137
+ if (exitCode !== null) {
138
+ resolve(exitCode);
139
+ return;
140
+ }
141
+ resolve(statusCodeForSignal(signalName));
142
+ });
143
+ });
144
+ }
145
+
146
+
147
+ export async function main(argumentsList = process.argv.slice(2), dependencies = {}) {
148
+ if (argumentsList.length === 0 || argumentsList.includes("--help") || argumentsList.includes("-h")) {
149
+ process.stdout.write(`${createHelpText()}\n`);
150
+ return 0;
151
+ }
152
+ if (argumentsList[0] !== lintCommandName) {
153
+ process.stderr.write(`${createHelpText()}\n`);
154
+ return 2;
155
+ }
156
+ const { interpreterOverride, remainingArguments, error } = takePythonOverride(argumentsList.slice(1));
157
+ if (error) {
158
+ process.stderr.write(`${error}\n`);
159
+ return 2;
160
+ }
161
+ const resolvePython = dependencies.findPython ?? findPython;
162
+ const interpreter = interpreterOverride ?? process.env[pythonEnvironmentName] ?? await resolvePython();
163
+ if (!interpreter) {
164
+ process.stderr.write("No usable Python interpreter found. Use --python or CDE_PYTHON.\n");
165
+ return 2;
166
+ }
167
+ const command = buildLintCommand(interpreter, remainingArguments);
168
+ const runCommand = dependencies.runCommand ?? runLintCommand;
169
+ return await runCommand(command, dependencies);
170
+ }
171
+
172
+
173
+ const isLaunchedDirectly = process.argv[1] !== undefined
174
+ && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
175
+ if (isLaunchedDirectly) process.exitCode = await main();