Skip to content

Commit 8161232

Browse files
ruimartinarcanis
andauthored
Sorting dependencies on install (#135)
* sorting dependencies on install * sorting only when running the install command * Tweaks --------- Co-authored-by: Maël Nison <mael.nison@mistral.ai>
1 parent ab8a3ee commit 8161232

File tree

6 files changed

+169
-14
lines changed

6 files changed

+169
-14
lines changed

documentation/package.json

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@
1919
"@astrojs/starlight-docsearch": "^0.6.0",
2020
"@astrojs/starlight-tailwind": "^4.0.1",
2121
"@base-ui-components/react": "^1.0.0-beta.1",
22+
"@clipanion/astro": "file:../scripts/@clipanion-astro.tgz",
23+
"@clipanion/expressive-code": "file:../scripts/@clipanion-expressive-code.tgz",
24+
"@clipanion/remark": "file:../scripts/@clipanion-remark.tgz",
25+
"@docsearch/css": "^4.1.0",
26+
"@docsearch/react": "^4.1.0",
2227
"@expressive-code/core": "^0.41.3",
2328
"@fontsource/montserrat": "^5.2.6",
2429
"@mdx-js/preact": "^3.1.0",
@@ -30,8 +35,10 @@
3035
"@yarnpkg/builder": "^4.2.2",
3136
"@yarnpkg/cli": "^4.9.2",
3237
"@yarnpkg/core": "^4.4.2",
38+
"@yarnpkg/parsers": "^3.0.3",
3339
"@yarnpkg/pnpify": "^4.1.5",
3440
"@yarnpkg/sdks": "^3.2.2",
41+
"algoliasearch": "^5.37.0",
3542
"astro": "^5.6.1",
3643
"clipanion": "^4.0.0-rc.4",
3744
"clsx": "^2.1.1",
@@ -41,15 +48,17 @@
4148
"es-toolkit": "^1.39.10",
4249
"git-url-parse": "^16.1.0",
4350
"glob": "^11.0.3",
51+
"instantsearch.js": "^4.80.0",
4452
"marked": "^16.0.0",
4553
"mdast-util-to-string": "^4.0.0",
4654
"path": "^0.12.7",
4755
"preact": "^10.26.9",
4856
"preact-iso": "^2.9.2",
57+
"preact-render-to-string": "^6.6.1",
58+
"react": "npm:@preact/compat@^18.3.1",
4959
"react-instantsearch": "^7.16.0",
5060
"react-is": "^19.1.1",
5161
"react-json-doc": "arcanis/react-json-doc",
52-
"react": "npm:@preact/compat@^18.3.1",
5362
"reading-time": "^1.5.0",
5463
"recharts": "^3.1.0",
5564
"resolve": "^1.22.10",
@@ -62,16 +71,7 @@
6271
"three": "^0.180.0",
6372
"unist-util-visit": "^5.0.0",
6473
"usehooks-ts": "^3.1.1",
65-
"@yarnpkg/parsers": "^3.0.3",
66-
"vite": "^7.1.7",
67-
"@docsearch/react": "^4.1.0",
68-
"algoliasearch": "^5.37.0",
69-
"instantsearch.js": "^4.80.0",
70-
"preact-render-to-string": "^6.6.1",
71-
"@clipanion/astro": "file:../scripts/@clipanion-astro.tgz",
72-
"@clipanion/expressive-code": "file:../scripts/@clipanion-expressive-code.tgz",
73-
"@clipanion/remark": "file:../scripts/@clipanion-remark.tgz",
74-
"@docsearch/css": "^4.1.0"
74+
"vite": "^7.1.7"
7575
},
7676
"devDependencies": {
7777
"@babel/core": "^7.27.7",

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@yarnpkg/monorepo",
3-
"packageManager": "yarn@6.0.0-git.20251007.hash-b589d4544f670cbe35ae229f7fedf919dd06af1c",
3+
"packageManager": "yarn@6.0.0-rc.6",
44
"workspaces": [
55
"documentation",
66
"packages/*"

packages/zpm-constraints/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
{
22
"dependencies": {
3-
"lodash": "^4.17.21",
43
"@types/node": "^24.0.10",
4+
"@yarnpkg/types": "^4.0.1",
55
"esbuild": "^0.25.5",
6-
"@yarnpkg/types": "^4.0.1"
6+
"lodash": "^4.17.21"
77
},
88
"devDependencies": {
99
"@types/lodash": "^4.17.17"

packages/zpm-parsers/src/json.rs

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use std::{collections::BTreeMap, ops::Range};
22

3+
use itertools::Itertools;
34
use serde::{Deserialize, Serialize, de::DeserializeOwned};
45

56
use crate::{document::Document, value::Indent, Error, Path, Value};
@@ -376,6 +377,62 @@ impl JsonDocument {
376377
self.insert_into_empty(offset, new_key, indent, value)
377378
}
378379

380+
pub fn sort_object_keys(&mut self, parent_path: &Path) -> Result<bool, Error> {
381+
let mut keys_by_position
382+
= self.paths.iter()
383+
.filter(|(path, _)| path.is_direct_child_of(parent_path))
384+
.map(|(path, &offset)| (path.last().unwrap(), offset))
385+
.collect_vec();
386+
387+
if keys_by_position.len() <= 1 {
388+
return Ok(false);
389+
}
390+
391+
keys_by_position.sort_by_key(|(_, offset)| *offset);
392+
393+
// Check if already sorted alphabetically
394+
if keys_by_position.windows(2).all(|w| w[0].0 <= w[1].0) {
395+
return Ok(false);
396+
}
397+
398+
// Extract each "key": value as raw bytes
399+
let mut key_value_pairs: Vec<(&str, Vec<u8>)> = vec![];
400+
let mut content_end_offset = 0usize;
401+
402+
for (key_name, offset) in &keys_by_position {
403+
let mut scanner
404+
= Scanner::new(&self.input, *offset);
405+
406+
scanner.skip_string()?;
407+
scanner.skip_whitespace();
408+
scanner.skip_char(b':')?;
409+
scanner.skip_whitespace();
410+
scanner.skip_value()?;
411+
412+
key_value_pairs.push((key_name, self.input[*offset..scanner.offset].to_vec()));
413+
content_end_offset = scanner.offset;
414+
}
415+
416+
// Detect separator pattern (e.g., ", " or ",\n ")
417+
let separator
418+
= self.input[key_value_pairs[0].1.len() + keys_by_position[0].1..keys_by_position[1].1].to_vec();
419+
420+
// Sort by key name and rebuild content
421+
key_value_pairs.sort_by_key(|(key_name, _)| *key_name);
422+
423+
let mut sorted_content
424+
= key_value_pairs[0].1.clone();
425+
426+
for (_, entry_bytes) in key_value_pairs.iter().skip(1) {
427+
sorted_content.extend_from_slice(&separator);
428+
sorted_content.extend_from_slice(entry_bytes);
429+
}
430+
431+
self.replace_range(keys_by_position[0].1..content_end_offset, &sorted_content)?;
432+
433+
Ok(true)
434+
}
435+
379436
/**
380437
* Return the indent level at the given offset. Return None if the given
381438
* offset is inline (i.e. not at the beginning of a line).
@@ -870,4 +927,51 @@ mod tests {
870927
document.set_path(&Path::from_segments(path.into_iter().map(|s| s.to_string()).collect()), value).unwrap();
871928
assert_eq!(String::from_utf8(document.input).unwrap(), String::from_utf8(expected.to_vec()).unwrap());
872929
}
930+
931+
// ===== sort_object_keys tests =====
932+
933+
#[rstest]
934+
// Sort unsorted keys at top level
935+
#[case(b"{\"zebra\": \"z\", \"apple\": \"a\", \"mango\": \"m\"}", vec![], b"{\"apple\": \"a\", \"mango\": \"m\", \"zebra\": \"z\"}", true)]
936+
937+
// Sort unsorted keys with newlines
938+
#[case(b"{\n \"zebra\": \"z\",\n \"apple\": \"a\",\n \"mango\": \"m\"\n}", vec![], b"{\n \"apple\": \"a\",\n \"mango\": \"m\",\n \"zebra\": \"z\"\n}", true)]
939+
940+
// Already sorted - no change
941+
#[case(b"{\"apple\": \"a\", \"mango\": \"m\", \"zebra\": \"z\"}", vec![], b"{\"apple\": \"a\", \"mango\": \"m\", \"zebra\": \"z\"}", false)]
942+
943+
// Already sorted with newlines - no change
944+
#[case(b"{\n \"apple\": \"a\",\n \"mango\": \"m\",\n \"zebra\": \"z\"\n}", vec![], b"{\n \"apple\": \"a\",\n \"mango\": \"m\",\n \"zebra\": \"z\"\n}", false)]
945+
946+
// Empty object - no change
947+
#[case(b"{}", vec![], b"{}", false)]
948+
949+
// Single key - no change
950+
#[case(b"{\"only\": \"key\"}", vec![], b"{\"only\": \"key\"}", false)]
951+
952+
// Sort nested object keys
953+
#[case(b"{\"deps\": {\"zebra\": \"1.0\", \"apple\": \"2.0\"}}", vec!["deps"], b"{\"deps\": {\"apple\": \"2.0\", \"zebra\": \"1.0\"}}", true)]
954+
955+
// Sort nested object with newlines
956+
#[case(b"{\n \"deps\": {\n \"zebra\": \"1.0\",\n \"apple\": \"2.0\"\n }\n}", vec!["deps"], b"{\n \"deps\": {\n \"apple\": \"2.0\",\n \"zebra\": \"1.0\"\n }\n}", true)]
957+
958+
// Non-existent path - no change
959+
#[case(b"{\"foo\": \"bar\"}", vec!["nonexistent"], b"{\"foo\": \"bar\"}", false)]
960+
961+
// Complex values preserved during sort
962+
#[case(b"{\"z\": {\"nested\": true}, \"a\": [1, 2, 3]}", vec![], b"{\"a\": [1, 2, 3], \"z\": {\"nested\": true}}", true)]
963+
964+
// Scoped package names sort correctly
965+
#[case(b"{\"deps\": {\"@types/node\": \"1.0\", \"@babel/core\": \"2.0\", \"lodash\": \"3.0\"}}", vec!["deps"], b"{\"deps\": {\"@babel/core\": \"2.0\", \"@types/node\": \"1.0\", \"lodash\": \"3.0\"}}", true)]
966+
967+
fn test_sort_object_keys(#[case] document: &[u8], #[case] path: Vec<&str>, #[case] expected: &[u8], #[case] expected_sorted: bool) {
968+
let mut document
969+
= JsonDocument::new(document.to_vec()).unwrap();
970+
971+
let sorted
972+
= document.sort_object_keys(&Path::from_segments(path.into_iter().map(|s| s.to_string()).collect())).unwrap();
973+
974+
assert_eq!(sorted, expected_sorted, "sort_object_keys return value mismatch");
975+
assert_eq!(String::from_utf8(document.input).unwrap(), String::from_utf8(expected.to_vec()).unwrap());
976+
}
873977
}

packages/zpm/src/commands/install.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use clipanion::cli;
22
use zpm_config::Source;
3+
use zpm_parsers::JsonDocument;
34

45
use crate::{error::Error, project::{self, InstallMode, RunInstallOptions}};
56

@@ -76,6 +77,8 @@ impl Install {
7677
project.config.settings.enable_immutable_cache.source = Source::Cli;
7778
}
7879

80+
sort_workspace_dependencies(&project)?;
81+
7982
project.run_install(RunInstallOptions {
8083
check_checksums: self.check_checksums,
8184
check_resolutions: self.check_resolutions,
@@ -87,3 +90,48 @@ impl Install {
8790
Ok(())
8891
}
8992
}
93+
94+
/// Sort dependency fields in all workspace package.json files alphabetically.
95+
/// This matches Yarn Berry behavior where dependencies are automatically sorted during install.
96+
fn sort_workspace_dependencies(project: &project::Project) -> Result<(), Error> {
97+
const DEPENDENCY_FIELDS: &[&str] = &[
98+
"dependencies",
99+
"devDependencies",
100+
"optionalDependencies",
101+
"peerDependencies",
102+
];
103+
104+
for workspace in &project.workspaces {
105+
let manifest_path = workspace.path
106+
.with_join_str("package.json");
107+
108+
let manifest_content = manifest_path
109+
.fs_read_prealloc()?;
110+
111+
let mut document
112+
= JsonDocument::new(manifest_content)?;
113+
114+
let mut any_sorted
115+
= false;
116+
117+
for field_name in DEPENDENCY_FIELDS {
118+
let field_path
119+
= zpm_parsers::Path::from_segments(vec![field_name.to_string()]);
120+
121+
if document.sort_object_keys(&field_path)? {
122+
any_sorted = true;
123+
}
124+
}
125+
126+
if any_sorted && project.config.settings.enable_immutable_installs.value {
127+
return Err(Error::ImmutablePackageManifest(manifest_path));
128+
}
129+
130+
if any_sorted {
131+
manifest_path
132+
.fs_change(&document.input, false)?;
133+
}
134+
}
135+
136+
Ok(())
137+
}

packages/zpm/src/error.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,9 @@ pub enum Error {
8787
#[error("Cannot autofix a lockfile when running an immutable install.")]
8888
ImmutableLockfileAutofix,
8989

90+
#[error("Found an incorrectly formatted package manifest when running an immutable install ({})", .0.to_print_string())]
91+
ImmutablePackageManifest(Path),
92+
9093
#[error("Git returned an error when attempting to autofix the lockfile: {0}")]
9194
LockfileAutofixGitError(String),
9295

0 commit comments

Comments
 (0)