wexample-wex-addon-dev-javascript 7.16.1__py3-none-any.whl → 8.1.0__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.
Files changed (18) hide show
  1. wexample_wex_addon_dev_javascript/config_value/javascript_package_readme_config_value.py +5 -2
  2. wexample_wex_addon_dev_javascript/file/javascript_package_json_file.py +11 -60
  3. wexample_wex_addon_dev_javascript/file/node_package_json_file.py +75 -0
  4. wexample_wex_addon_dev_javascript/formatter/__init__.py +0 -0
  5. wexample_wex_addon_dev_javascript/formatter/javascript_code_formatter.py +43 -0
  6. wexample_wex_addon_dev_javascript/javascript_addon_manager.py +32 -2
  7. wexample_wex_addon_dev_javascript/resources/design_rules/javascript_code.md +17 -0
  8. wexample_wex_addon_dev_javascript/resources/writing_rules/javascript_code.md +26 -0
  9. wexample_wex_addon_dev_javascript/services/node/commands/__init__.py +0 -0
  10. wexample_wex_addon_dev_javascript/services/node/commands/service/__init__.py +0 -0
  11. wexample_wex_addon_dev_javascript/services/node/commands/service/install_local.py +81 -0
  12. wexample_wex_addon_dev_javascript/services/node/commands/service/refresh_lock.py +71 -0
  13. wexample_wex_addon_dev_javascript/workdir/javascript_package_workdir.py +38 -0
  14. wexample_wex_addon_dev_javascript-8.1.0.dist-info/METADATA +309 -0
  15. {wexample_wex_addon_dev_javascript-7.16.1.dist-info → wexample_wex_addon_dev_javascript-8.1.0.dist-info}/RECORD +17 -8
  16. wexample_wex_addon_dev_javascript-7.16.1.dist-info/METADATA +0 -475
  17. {wexample_wex_addon_dev_javascript-7.16.1.dist-info → wexample_wex_addon_dev_javascript-8.1.0.dist-info}/WHEEL +0 -0
  18. {wexample_wex_addon_dev_javascript-7.16.1.dist-info → wexample_wex_addon_dev_javascript-8.1.0.dist-info}/entry_points.txt +0 -0
@@ -10,5 +10,8 @@ from wexample_wex_addon_app.config_value.app_readme_config_value import (
10
10
  class JavascriptPackageReadmeContentConfigValue(AppReadmeConfigValue):
11
11
  """README generation for Javascript packages."""
12
12
 
13
- def _get_app_description(self) -> str:
14
- return self.workdir.get_app_config().get("description", "")
13
+ def _get_app_description(self) -> str | None:
14
+ return (
15
+ self.workdir.get_app_config().get("description")
16
+ or super()._get_app_description()
17
+ )
@@ -1,60 +1,21 @@
1
1
  from __future__ import annotations
2
2
 
3
- from wexample_filestate.item.file.json_file import JsonFile
4
3
  from wexample_helpers.decorator.base_class import base_class
5
- from wexample_wex_addon_app.item.file.mixin.app_dependencies_config_file_mixin import (
6
- AppDependenciesConfigFileMixin,
4
+
5
+ from wexample_wex_addon_dev_javascript.file.node_package_json_file import (
6
+ NodePackageJsonFile,
7
7
  )
8
8
 
9
9
 
10
10
  @base_class
11
- class JavascriptPackageJsonFile(AppDependenciesConfigFileMixin, JsonFile):
12
- def add_dependency(
13
- self,
14
- operator: str = "",
15
- **kwargs,
16
- ) -> bool:
17
- # NPM uses bare versions (or ^/~), not "==".
18
- return super().add_dependency(
19
- operator=operator,
20
- **kwargs,
21
- )
22
-
23
- def add_dependency_from_string(
24
- self,
25
- package_name: str,
26
- version: str,
27
- operator: str = "",
28
- optional: bool = False,
29
- group: None | str = None,
30
- ) -> bool:
31
- """
32
- Add or update an npm dependency entry from a raw package name + version string.
33
- Equivalent to add_dependency() but without requiring a CodeBaseWorkdir.
34
- """
35
- if optional:
36
- section = "optionalDependencies"
37
- elif group == "dev":
38
- section = "devDependencies"
39
- elif group:
40
- section = group
41
- else:
42
- section = "dependencies"
43
-
44
- constraint = f"{operator}{version}".strip()
45
-
46
- config = self.read_config()
47
- deps_node = config.search(path=section, default={})
48
- deps = deps_node.to_dict() if deps_node else {}
49
-
50
- if deps.get(package_name) == constraint:
51
- return False
52
-
53
- deps[package_name] = constraint
54
- config.update_nested({section: deps})
55
- self.write_config(config)
56
-
57
- return True
11
+ class JavascriptPackageJsonFile(NodePackageJsonFile):
12
+ """A published javascript package's package.json.
13
+
14
+ On top of the dependency-manifest API, publication metadata (name,
15
+ version, repository, publishConfig, exports...) is derived from the
16
+ workdir at dump time — which is why this class must never be attached
17
+ to an app manifest (see NodePackageJsonFile).
18
+ """
58
19
 
59
20
  def dumps(self, content: dict | None = None) -> str:
60
21
  content = content or self.read_parsed()
@@ -71,16 +32,6 @@ class JavascriptPackageJsonFile(AppDependenciesConfigFileMixin, JsonFile):
71
32
 
72
33
  return super().dumps(content)
73
34
 
74
- def get_dependencies_versions(
75
- self, optional: bool = False, group: str = "dev"
76
- ) -> dict[str, str]:
77
- config = self.read_config()
78
- # Use to_dict_or_none() (not get_dict_or_default) so nested ConfigValue
79
- # wrappers are unwrapped to native str — matches the dict[str, str] signature.
80
- deps = config.search(path="dependencies").to_dict_or_none() or {}
81
- peer = config.search(path="peerDependencies").to_dict_or_none() or {}
82
- return {**deps, **peer}
83
-
84
35
  def _apply_default_publish_config(self, content: dict) -> None:
85
36
  content.setdefault("type", "module")
86
37
 
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ from wexample_filestate.item.file.json_file import JsonFile
4
+ from wexample_helpers.decorator.base_class import base_class
5
+ from wexample_wex_addon_app.item.file.mixin.app_dependencies_config_file_mixin import (
6
+ AppDependenciesConfigFileMixin,
7
+ )
8
+
9
+
10
+ @base_class
11
+ class NodePackageJsonFile(AppDependenciesConfigFileMixin, JsonFile):
12
+ """A package.json exposing the dependency-manifest API.
13
+
14
+ Suits any workdir shipping a package.json (polyglot apps included);
15
+ JavascriptPackageJsonFile extends it with package-publication concerns
16
+ (name/version stamping, publishConfig...) that must never touch an
17
+ app manifest.
18
+ """
19
+
20
+ def add_dependency(
21
+ self,
22
+ operator: str = "",
23
+ **kwargs,
24
+ ) -> bool:
25
+ # NPM uses bare versions (or ^/~), not "==".
26
+ return super().add_dependency(
27
+ operator=operator,
28
+ **kwargs,
29
+ )
30
+
31
+ def add_dependency_from_string(
32
+ self,
33
+ package_name: str,
34
+ version: str,
35
+ operator: str = "",
36
+ optional: bool = False,
37
+ group: None | str = None,
38
+ ) -> bool:
39
+ """
40
+ Add or update an npm dependency entry from a raw package name + version string.
41
+ Equivalent to add_dependency() but without requiring a CodeBaseWorkdir.
42
+ """
43
+ if optional:
44
+ section = "optionalDependencies"
45
+ elif group == "dev":
46
+ section = "devDependencies"
47
+ elif group:
48
+ section = group
49
+ else:
50
+ section = "dependencies"
51
+
52
+ constraint = f"{operator}{version}".strip()
53
+
54
+ config = self.read_config()
55
+ deps_node = config.search(path=section, default={})
56
+ deps = deps_node.to_dict() if deps_node else {}
57
+
58
+ if deps.get(package_name) == constraint:
59
+ return False
60
+
61
+ deps[package_name] = constraint
62
+ config.update_nested({section: deps})
63
+ self.write_config(config)
64
+
65
+ return True
66
+
67
+ def get_dependencies_versions(
68
+ self, optional: bool = False, group: str = "dev"
69
+ ) -> dict[str, str]:
70
+ config = self.read_config()
71
+ # Use to_dict_or_none() (not get_dict_or_default) so nested ConfigValue
72
+ # wrappers are unwrapped to native str — matches the dict[str, str] signature.
73
+ deps = config.search(path="dependencies").to_dict_or_none() or {}
74
+ peer = config.search(path="peerDependencies").to_dict_or_none() or {}
75
+ return {**deps, **peer}
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, ClassVar
4
+
5
+ from wexample_helpers.decorator.base_class import base_class
6
+ from wexample_wex_addon_ai.formatter.abstract_formatter import AbstractFormatter
7
+
8
+ if TYPE_CHECKING:
9
+ from pathlib import Path
10
+
11
+
12
+ @base_class
13
+ class JavascriptCodeFormatter(AbstractFormatter):
14
+ """TypeScript and JavaScript shipped by an npm package, under its `src/`.
15
+
16
+ Covers both extensions because the packages are TypeScript sources published as
17
+ ESM, and a `.js` under `src/` is the same code with the types left out. Build
18
+ output, tests and config files answer elsewhere.
19
+ """
20
+
21
+ FORMATTER_NAME: ClassVar[str] = "javascript-code"
22
+ SOURCE_SUFFIXES: ClassVar[set[str]] = {".ts", ".tsx", ".js", ".jsx", ".mts", ".cts"}
23
+
24
+ def get_design_rules(self) -> str:
25
+ return self.read_resource("design_rules", "javascript_code.md")
26
+
27
+ def get_writing_rules(self) -> str:
28
+ return self.read_resource("writing_rules", "javascript_code.md")
29
+
30
+ def matches_path(self, path: Path) -> bool:
31
+ """A source file under the `src/` of a directory holding a `package.json`.
32
+
33
+ `node_modules` is excluded outright: every dependency in it carries its own
34
+ `package.json` and would otherwise match exactly like the package that
35
+ installed it.
36
+ """
37
+ if path.suffix not in self.SOURCE_SUFFIXES or "node_modules" in path.parts:
38
+ return False
39
+
40
+ for parent in path.parents:
41
+ if parent.name == "src" and (parent.parent / "package.json").exists():
42
+ return True
43
+ return False
@@ -1,7 +1,37 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from typing import TYPE_CHECKING
4
+
5
+ from wexample_wex_addon_ai.formatter.formatter_contributing_addon_mixin import (
6
+ FormatterContributingAddonMixin,
7
+ )
3
8
  from wexample_wex_core.common.abstract_addon_manager import AbstractAddonManager
4
9
 
10
+ if TYPE_CHECKING:
11
+ from wexample_wex_addon_ai.formatter.abstract_formatter import AbstractFormatter
12
+
13
+
14
+ class JavascriptAddonManager(FormatterContributingAddonMixin, AbstractAddonManager):
15
+ def get_formatter_classes(self) -> list[type[AbstractFormatter]]:
16
+ from wexample_wex_addon_dev_javascript.formatter.javascript_code_formatter import (
17
+ JavascriptCodeFormatter,
18
+ )
19
+
20
+ return [JavascriptCodeFormatter]
21
+
22
+ def get_workdir_types(self) -> dict[str, type]:
23
+ from wexample_wex_addon_dev_javascript.workdir.javascript_package_workdir import (
24
+ JavascriptPackageWorkdir,
25
+ )
26
+ from wexample_wex_addon_dev_javascript.workdir.javascript_packages_suite_workdir import (
27
+ JavascriptPackagesSuiteWorkdir,
28
+ )
29
+ from wexample_wex_addon_dev_javascript.workdir.javascript_workdir import (
30
+ JavascriptWorkdir,
31
+ )
5
32
 
6
- class JavascriptAddonManager(AbstractAddonManager):
7
- pass
33
+ return {
34
+ "javascript": JavascriptWorkdir,
35
+ "javascript-package": JavascriptPackageWorkdir,
36
+ "javascript-packages-suite": JavascriptPackagesSuiteWorkdir,
37
+ }
@@ -0,0 +1,17 @@
1
+ This file is design rules for TypeScript shipped by an npm package, under its `src/`.
2
+ Decide the layout below before opening an editor — by the time a file is being written,
3
+ its path is already a decision made.
4
+
5
+ - One class per file, one file per class, and the file name is the class name in
6
+ PascalCase — `Common/AsyncConstructor.ts` holds `AsyncConstructor` and nothing else.
7
+ The class is the file's `export default`.
8
+ - Helper functions are grouped by the type they act on, one file per type, named after
9
+ it: `Helper/Array.ts`, `Helper/String.ts`, `Helper/Dom.ts`. They are named exports,
10
+ never a default, and each name is prefixed with the file's subject —
11
+ `arrayShallowCopy`, `stringToKebabCase`. The prefix is what makes the import readable
12
+ at the call site, where the file name is no longer visible.
13
+ - Kinds live apart, each in its own directory under `src/`: classes in `Common/`, helper
14
+ functions in `Helper/`. A class dropped next to the helpers because it serves them is
15
+ the case to move.
16
+ - No barrel files. Import from the module that defines the symbol, so that a consumer
17
+ bundling one helper does not pull the package in.
@@ -0,0 +1,26 @@
1
+ This file is TypeScript shipped by an npm package, under its `src/`. The file's path is
2
+ already decided by the time these rules apply — see
3
+ `ai::design/rules --formatter javascript-code` for the layout decisions that come before
4
+ it.
5
+
6
+ - Code, names, comments and doc comments are in English, whatever language the
7
+ conversation is held in.
8
+ - Relative imports carry the `.js` extension even though the source is `.ts` —
9
+ `import { functionIsType } from '../Helper/Function.js'`. The packages publish native
10
+ ESM, where the specifier is resolved as written at runtime and an extensionless path
11
+ has nothing to resolve.
12
+ - The packages compile under `strict`, so every parameter and every return is typed, and
13
+ `any` is a decision to justify rather than a default. Prefer a generic that keeps the
14
+ caller's type (`<T>(array: T[]): T[]`) over widening to `unknown[]` and casting back.
15
+ - A `try` is occasional and justified: it catches a failure somewhere that knows what to do
16
+ about it. Wrapping a call defensively, or writing a `catch` that swallows and a `finally`
17
+ that lets the failure pass, turns a bug into a wrong result reported nowhere. Let it
18
+ throw — a stack trace at the point of failure is worth more than a silent fallback
19
+ discovered three layers later.
20
+ - Defensive code goes the same way: a guard against a state that cannot happen, a `typeof`
21
+ check on something the type already guarantees, a `?.` or `|| fallback` on a value that
22
+ is never missing. Validate at the boundary — user input, an external API, a parsed
23
+ payload — and trust what our own code hands you.
24
+ - Comment only what the code cannot say: a constraint, an invariant, the reason a
25
+ surprising line is the way it is. A comment that no longer matches the code it
26
+ describes is deleted, not updated around.
@@ -0,0 +1,81 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from wexample_cli.const.tags import AudienceTag, EffectTag, ScopeTag
6
+ from wexample_cli.decorator.command import command
7
+ from wexample_wex_core.const.globals import COMMAND_TYPE_SERVICE
8
+
9
+ from wexample_wex_addon_dev_javascript.const.tags import DomainTag
10
+
11
+ if TYPE_CHECKING:
12
+ from wexample_cli.context.execution_context import ExecutionContext
13
+ from wexample_wex_addon_app.service.app_service import AppService
14
+
15
+ # In-container layout convention shared by the local dev composes: the app is
16
+ # served from APP_DIR and each locally-developed javascript suite is mounted
17
+ # under JS_DEV_DIR/<vendor> (see the `local_packages` env config section).
18
+ APP_DIR = "/var/www/html"
19
+ JS_DEV_DIR = "/var/www/javascript-dev"
20
+
21
+
22
+ @command(
23
+ type=COMMAND_TYPE_SERVICE,
24
+ description="Wire locally-developed npm packages into node_modules/ (local env only)",
25
+ tags=[
26
+ DomainTag.FRONTEND,
27
+ EffectTag.WRITE,
28
+ AudienceTag.AGENT_SAFE,
29
+ ScopeTag.APP,
30
+ ScopeTag.CONTAINER,
31
+ ScopeTag.LOCAL,
32
+ ],
33
+ )
34
+ def node__service__install_local(
35
+ context: ExecutionContext,
36
+ service: AppService,
37
+ ) -> None:
38
+ """Generic node_modules wiring, reusable by any service whose container
39
+ runs the app's javascript toolchain (symfony, laravel, vite…).
40
+
41
+ Delegating services call this with their own ``service`` so the work runs
42
+ inside their container.
43
+ """
44
+ js_packages = service.app_workdir.get_runtime_config().search(
45
+ "local_packages.javascript"
46
+ )
47
+ if js_packages.is_none():
48
+ context.io.log("No local_packages.javascript configured, nothing to wire.")
49
+ return
50
+
51
+ vendors = " ".join(sorted(js_packages.to_dict().keys()))
52
+
53
+ # Package managers keep the (possibly stale) registry version even when a
54
+ # newer local source exists, so the deterministic wiring is a forced
55
+ # symlink over node_modules/ — resolved by each package's declared scoped
56
+ # name, which differs from its directory name.
57
+ script = f"""
58
+ set -e
59
+ cd {APP_DIR}
60
+ if [ -f yarn.lock ]; then yarn install
61
+ elif [ -f pnpm-lock.yaml ]; then pnpm install
62
+ else npm install
63
+ fi
64
+ for vendor in {vendors}; do
65
+ [ -d "{JS_DEV_DIR}/$vendor" ] || continue
66
+ for src in {JS_DEV_DIR}/$vendor/*/; do
67
+ [ -f "$src/package.json" ] || continue
68
+ name=$(node -p "require('$src/package.json').name")
69
+ mkdir -p "node_modules/$(dirname "$name")"
70
+ rm -rf "node_modules/$name"
71
+ ln -s "${{src%/}}" "node_modules/$name"
72
+ echo " symlinked $name"
73
+ done
74
+ done
75
+ """
76
+
77
+ context.io.log(f"Wiring local npm packages ({vendors}) into node_modules/…")
78
+ output = service.addon_manager.docker_exec(
79
+ service.name, ["/bin/sh", "-c", script]
80
+ )
81
+ context.io.log(output)
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from wexample_cli.const.tags import AudienceTag, EffectTag, ScopeTag
6
+ from wexample_cli.decorator.command import command
7
+ from wexample_cli.decorator.option import option
8
+ from wexample_wex_core.const.globals import COMMAND_TYPE_SERVICE
9
+
10
+ from wexample_wex_addon_dev_javascript.const.tags import DomainTag
11
+ from wexample_wex_addon_dev_javascript.services.node.commands.service.install_local import (
12
+ APP_DIR,
13
+ )
14
+
15
+ if TYPE_CHECKING:
16
+ from wexample_cli.context.execution_context import ExecutionContext
17
+ from wexample_wex_addon_app.service.app_service import AppService
18
+
19
+
20
+ @option(
21
+ name="npm_packages",
22
+ type=str,
23
+ required=False,
24
+ default="",
25
+ description="Space-separated npm package names whose constraint changed in package.json",
26
+ )
27
+ @command(
28
+ type=COMMAND_TYPE_SERVICE,
29
+ description="Refresh the javascript lock file after a package.json dependency change",
30
+ tags=[
31
+ DomainTag.FRONTEND,
32
+ EffectTag.WRITE,
33
+ AudienceTag.AGENT_SAFE,
34
+ ScopeTag.APP,
35
+ ScopeTag.CONTAINER,
36
+ ScopeTag.LOCAL,
37
+ ],
38
+ )
39
+ def node__service__refresh_lock(
40
+ context: ExecutionContext,
41
+ service: AppService,
42
+ npm_packages: str = "",
43
+ ) -> None:
44
+ """Generic javascript lock refresh, reusable by any service whose
45
+ container runs the app's javascript toolchain.
46
+
47
+ The lock file present in the app tells which package manager owns it.
48
+ Yarn classic has no lock-only mode, so node_modules is (re)installed
49
+ along the way — harmless inside the app container.
50
+ """
51
+ # The hook reaches every service, carrying only the manifests that moved.
52
+ if not npm_packages:
53
+ return
54
+
55
+ script = f"""
56
+ set -e
57
+ cd {APP_DIR}
58
+ if [ -f yarn.lock ]; then yarn install
59
+ elif [ -f pnpm-lock.yaml ]; then pnpm install --lockfile-only
60
+ elif [ -f package-lock.json ]; then npm install --package-lock-only
61
+ else
62
+ echo "No javascript lock file found in {APP_DIR}" >&2
63
+ exit 1
64
+ fi
65
+ """
66
+
67
+ context.io.log(f"Refreshing javascript lock file ({npm_packages})…")
68
+ output = service.addon_manager.docker_exec(
69
+ service.name, ["/bin/sh", "-c", script]
70
+ )
71
+ context.io.log(output)
@@ -35,6 +35,14 @@ class JavascriptPackageWorkdir(JavascriptWorkdir):
35
35
  def get_project_name(self) -> str:
36
36
  return f"@{self.get_vendor_name()}/{string_to_kebab_case(super().get_project_name())}"
37
37
 
38
+ # Lockfile → package manager + its lockfile-only refresh command. The
39
+ # lockfile present in the repo tells which manager the project uses.
40
+ _LOCKFILE_REFRESH_COMMANDS: dict[str, list[str]] = {
41
+ "package-lock.json": ["npm", "install", "--package-lock-only"],
42
+ "pnpm-lock.yaml": ["pnpm", "install", "--lockfile-only"],
43
+ "yarn.lock": ["yarn", "install", "--mode", "update-lockfile"],
44
+ }
45
+
38
46
  def prepare_value(self, raw_value: DictConfig | None = None) -> DictConfig:
39
47
  from wexample_filestate.const.disk import DiskItemType
40
48
  from wexample_helpers.helper.file import file_read
@@ -76,8 +84,20 @@ class JavascriptPackageWorkdir(JavascriptWorkdir):
76
84
 
77
85
  return raw_value
78
86
 
87
+ def release(self, **kwargs) -> None:
88
+ # The lockfile must follow package.json: CI installs run in strict
89
+ # mode (`npm ci` & co) and fail hard on any desync between the two.
90
+ # A sibling's propagate step may have bumped constraints here, so the
91
+ # refresh happens at release start — NOT at propagate time, when the
92
+ # sibling's new version is not registry-available yet (it publishes,
93
+ # then waits for the registry, before dependents release: the suite
94
+ # loop follows the dependency graph).
95
+ self._refresh_lockfile()
96
+ return super().release(**kwargs)
97
+
79
98
  def _classify_version_bump(self, last_tag: str) -> str:
80
99
  from wexample_helpers.const.types import (
100
+ UPGRADE_TYPE_INTERMEDIATE,
81
101
  UPGRADE_TYPE_MAJOR,
82
102
  UPGRADE_TYPE_MINOR,
83
103
  )
@@ -147,6 +167,24 @@ class JavascriptPackageWorkdir(JavascriptWorkdir):
147
167
 
148
168
  git_push_tag(tag, cwd=cwd, remote=remote, inherit_stdio=True)
149
169
 
170
+ def _refresh_lockfile(self) -> None:
171
+ import shutil
172
+
173
+ from wexample_helpers.helper.shell import shell_run
174
+
175
+ for lockfile, command in self._LOCKFILE_REFRESH_COMMANDS.items():
176
+ if not (self.get_path() / lockfile).exists():
177
+ continue
178
+ if shutil.which(command[0]) is None:
179
+ raise RuntimeError(
180
+ f"{lockfile} needs a refresh after a dependency change in "
181
+ f"{self.get_project_name()}, but '{command[0]}' is not "
182
+ f"available on this machine. A stale lockfile would break "
183
+ f"the strict CI install."
184
+ )
185
+ shell_run(command, cwd=self.get_path())
186
+ return
187
+
150
188
  def _wait_for_registry(self) -> None:
151
189
  """Poll the configured npm registry until the current version is available (max 20 min).
152
190