Skip to content

Commit 643d74f

Browse files
committed
Update package manifest schema
1 parent 5d59642 commit 643d74f

9 files changed

Lines changed: 93 additions & 53 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ Interactive text output uses inspection spinners and progress bars with a packag
4949

5050
## Metadata and architecture
5151

52-
Only the `singer` category has its declaration file parsed. All other categories, including `inference`, retain their expanded `desc.json` entry and expose category, ID and any checked direct file path. Their declaration files are not parsed, their variables are not expanded, and declaration fields are not extracted or indexed. Their category and ID remain indexed and usable in contribution selectors. No name or declaration expansion state is fabricated for them. Singer contributions additionally expose common module fields, avatar, background and demo audio when extractable. Contribution-specific fields and import/export relationships are not validated. String `path` values directly in `desc.json` and package readme paths must reference regular files inside the package. Resource paths within contribution declarations are neither checked nor opened. A non-string `path` in an opaque category is retained as category-owned data.
52+
Only the `singer` category has its declaration file parsed. All other categories, including `inference`, retain their expanded `desc.json` entry and expose category, ID and any checked direct file path. Their declaration files are not parsed, their variables are not expanded, and declaration fields are not extracted or indexed. Their category and ID remain indexed and usable in contribution selectors. No name or declaration expansion state is fabricated for them. Singer contributions additionally expose common module fields, avatar, background and demo audio when extractable. Contribution-specific fields and import/export relationships are not validated. String `path` values directly in `desc.json` and package license and readme paths must reference regular files inside the package. Resource paths within contribution declarations are neither checked nor opened. A non-string `path` in an opaque category is retained as category-owned data.
5353

5454
The package manifest and singer JSON declarations support UTF-8 BOM and comments, reject duplicate keys and trailing commas, and preserve unknown fields. Variable expansion follows declaration order and scope and never rescans substituted text. Parsing limits include 16 MiB per parsed declaration, 128 levels, one million value nodes, and a 64 MiB cumulative metadata budget. Malformed singer fields produce extraction diagnostics rather than installation failures; unreadable or invalid singer JSON declarations are rejected. These JSON requirements do not apply to opaque contribution declaration files. All package files still participate in archive integrity checks, content hashing and extraction limits.
5555

‎docs/installed-database.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ def content_hash(files):
172172

173173
## Metadata Ownership
174174

175-
The database is an installation-time management and listing index. `desc.json` retains complete package metadata and opaque entries. Singer declarations retain complete singer metadata. The database excludes declaration bodies, declaration paths, expansion state, copyright, readme, URL, singer media fields, and import or export relationships. It also excludes resolved dependency edges, source archive paths, remote repository records, and installed file inventories.
175+
The database is an installation-time management and listing index. `desc.json` retains complete package metadata and opaque entries. Singer declarations retain complete singer metadata. The database excludes declaration bodies, declaration paths, expansion state, license paths, readme paths, URL, singer media fields, and import or export relationships. It also excludes resolved dependency edges, source archive paths, remote repository records, and installed file inventories.
176176

177177
Installed package contents are immutable under the management protocol. Replacement uses installation with `--overwrite-existing` if the same identity has different content. External file edits can produce stale indexed metadata, dependencies, and recorded hashes. `list` does not rescan declarations. `info --installed` reopens package and singer metadata without reindexing or recomputing the installed hash. A registered identity mismatch is rejected. Opaque declaration files are not parsed by information queries.
178178

‎docs/interoperability.md‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ The root version response contains no query envelope. The executable version is
9797
| Category, interface, or variant ID | The string contains contribution ID segments separated by `.`. Category IDs are extensible and case-sensitive. |
9898
| Package version | The string contains four decimal integer components, such as `"1.2.0.0"`. Components have arbitrary precision and no leading zeros except the value `0`. |
9999
| `Text` | The object maps language keys to strings and includes the default key `_`. Extracted shorthand strings become `{"_":"value"}`. Empty strings and all language keys are retained. |
100+
| `FilePaths` | The object has the `Text` representation. Every value is a nonempty, fully qualified, lexically normalized host path to a regular file inside the package. |
100101
| `Dependency` | The object contains a package `id` and normalized target `version`. The target does not identify a resolved provider version. |
101102
| Content hash | The string contains 128 lowercase hexadecimal characters representing the [content tree digest](installed-database.md#content-hash). |
102103

@@ -135,22 +136,24 @@ A singer contribution has category ID exactly equal to `singer`. An opaque contr
135136

136137
### Package Manifest
137138

138-
`PackageManifest` contains required `id`, `version`, `formatVersion`, `runtimeLevel`, `compatVersion`, `dependencies`, `contributions`, and `manifest`. Optional fields are `name`, `description`, `vendor`, `copyright`, `readme`, `url`, and `diagnostics`. `dependencies` and `contributions` are arrays, including `[]` if empty. `diagnostics`, if present, is a nonempty string array.
139+
`PackageManifest` contains required `id`, `version`, `formatVersion`, `runtimeLevel`, `compatVersion`, `dependencies`, `contributions`, and `manifest`. Optional fields are `name`, `description`, `vendor`, `license`, `readme`, `url`, and `diagnostics`. `dependencies` and `contributions` are arrays, including `[]` if empty. `diagnostics`, if present, is a nonempty string array.
139140

140141
| Declaration field | Extracted representation |
141142
| --- | --- |
142143
| `$version` | `formatVersion` contains the normalized format version. The supported value is `"1.0.0.0"`. |
143144
| `runtimeLevel` | `runtimeLevel` contains the supported integer value `1`. |
144145
| `id`, `version`, `compatVersion` | The extracted fields retain their names. Versions are normalized. An absent `compatVersion` defaults to `version`. |
145-
| `name`, `description`, `vendor`, `copyright` | Each extracted field contains a complete `Text` object. |
146-
| `readme` | The extracted `Text` object contains fully qualified, lexically normalized host paths. Every localized path must reference a regular file within the package. |
146+
| `name`, `description`, `vendor` | Each extracted field contains a complete `Text` object. |
147+
| `license`, `readme` | Each extracted field contains a complete `FilePaths` object. All language keys are retained. |
147148
| `url` | The extracted field contains the expanded string. An absent field is omitted. An explicit empty string remains `""`. URI syntax and network availability are not checked. |
148149
| `dependencies`, `contributions` | The extracted arrays contain declared dependencies and full contribution objects. |
149150
| `vars` | Variable definitions are consumed during expansion and omitted from the expanded manifest. |
150151

151-
`manifest` contains expanded `desc.json` with arbitrary fields and JSON types retained. It does not preserve source formatting, comments, or byte order marks. Typed normalization does not modify the retained declaration representation. For example, `manifest.version` can contain `"1.0"` while the typed `version` contains `"1.0.0.0"`. `manifest.readme` contains expanded declaration paths while typed `readme` contains resolved host paths.
152+
`license` and `readme` use identical path rules. A declaration accepts a shorthand string such as `"license": "LICENSE.txt"` or a language map such as `"license": {"_": "LICENSE.txt", "zh-CN": "docs/许可证.txt"}`. A language map requires the default key `_`. Variable expansion precedes path resolution. Every localized path is checked, regardless of the selected output language. Null values, empty paths, missing files, directories, and references outside the package are rejected. An absent field remains omitted.
152153

153-
The database stores package name, description, and vendor for installed package summaries. Copyright, readme, URL, and arbitrary extension fields remain available through full information responses and are absent from installed package summaries.
154+
`manifest` contains expanded `desc.json` with arbitrary fields and JSON types retained. It does not preserve source formatting, comments, or byte order marks. Typed normalization does not modify the retained declaration representation. For example, `manifest.version` can contain `"1.0"` while the typed `version` contains `"1.0.0.0"`. `manifest.license` and `manifest.readme` contain expanded declaration paths while typed `license` and `readme` contain resolved host paths.
155+
156+
The database stores package name, description, and vendor for installed package summaries. License paths, readme paths, URL, and arbitrary extension fields remain available through full information responses and are absent from installed package summaries.
154157

155158
### Contributions
156159

@@ -178,7 +181,7 @@ Full singer `level` uses a JSON number. Indexed summaries and the database use d
178181

179182
An installed contribution selector filters only `package.contributions`. `package.manifest` and `installation.contributions` retain the complete package declaration and index.
180183

181-
`${root}`, `${dir}`, and typed readme paths depend on the read context. Installed information uses the installed package directory. Pack planning uses the source directory. Archive information uses the archive parent directory as a logical root without extracting files there. A singer declaration has `${dir}` set to its containing directory. Extracted singer resource strings are not independently resolved or normalized. Expanded absolute paths are specific to the applicable read context.
184+
`${root}`, `${dir}`, and typed license and readme paths depend on the read context. Installed information uses the installed package directory. Pack planning uses the source directory. Archive information uses the archive parent directory as a logical root without extracting files there. A singer declaration has `${dir}` set to its containing directory. Extracted singer resource strings are not independently resolved or normalized. Expanded absolute paths are specific to the applicable read context.
182185

183186
## Mutation Envelopes
184187

‎docs/schemas/cli-output.schema.json‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@
4343
"additionalProperties": { "type": "string" },
4444
"description": "The object retains all language keys and explicit empty strings. The key '_' identifies the default value."
4545
},
46+
"filePaths": {
47+
"$ref": "#/$defs/text",
48+
"additionalProperties": { "type": "string", "minLength": 1 },
49+
"description": "Each localized value is a nonempty, fully qualified, lexically normalized host path in the package read context. Each reference must identify a regular file inside the package."
50+
},
4651
"diagnosticArray": {
4752
"type": "array",
4853
"items": { "type": "string" }
@@ -159,8 +164,8 @@
159164
"name": { "$ref": "#/$defs/text" },
160165
"description": { "$ref": "#/$defs/text" },
161166
"vendor": { "$ref": "#/$defs/text" },
162-
"copyright": { "$ref": "#/$defs/text" },
163-
"readme": { "$ref": "#/$defs/text", "description": "Each localized value is a fully qualified, lexically normalized host path in the package read context. Each reference must identify a regular file inside the package." },
167+
"license": { "$ref": "#/$defs/filePaths" },
168+
"readme": { "$ref": "#/$defs/filePaths" },
164169
"url": { "type": "string", "description": "The value is the website string after variable expansion. An explicit empty string is retained. The field is omitted if absent." },
165170
"manifest": { "type": "object", "description": "The object contains expanded desc.json with vars removed. Arbitrary package fields and JSON types are retained. Source formatting and comments are excluded." },
166171
"diagnostics": { "$ref": "#/$defs/diagnosticArray" }

‎internal/cli/metadata_test.go‎

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@ import (
1414
func TestInfoCompleteMetadataFromArchiveAndInstallation(t *testing.T) {
1515
scratch := t.TempDir()
1616
source, root, archive := filepath.Join(scratch, "source"), filepath.Join(scratch, "packages"), filepath.Join(scratch, "voice.dspk")
17-
desc := []byte(`{"$version":"1","runtimeLevel":1,"id":"vendor/voice","version":"1","vars":[{"name":"title","value":"Voice"},{"name":"docs","value":"${root}/docs"}],"name":"${title}","vendor":"Vendor","description":"Description ${title}","copyright":{"_":"","en":"Copyright ${title}","zh-CN":"版权 ${title}"},"readme":{"_":"docs/../docs/readme.txt","zh-CN":"${docs}/说明.txt"},"url":"https://example.test/${title}","contributions":{"singer":[{"id":"main","path":"singer.json"}],"vendor.custom":[{"id":"data","configuration":{"file":"../../unchecked"}}]},"extension":{"text":"${title}","enabled":true}}`)
17+
desc := []byte(`{"$version":"1","runtimeLevel":1,"id":"vendor/voice","version":"1","vars":[{"name":"title","value":"Voice"},{"name":"docs","value":"${root}/docs"}],"name":"${title}","vendor":"Vendor","description":"Description ${title}","license":{"_":"docs/../docs/LICENSE-${title}.txt","en":"${docs}/LICENSE.en.txt","zh-CN":"${docs}/许可证.txt"},"readme":{"_":"docs/../docs/readme.txt","zh-CN":"${docs}/说明.txt"},"url":"https://example.test/${title}","contributions":{"singer":[{"id":"main","path":"singer.json"}],"vendor.custom":[{"id":"data","configuration":{"file":"../../unchecked"}}]},"extension":{"text":"${title}","enabled":true}}`)
1818
for name, content := range map[string][]byte{
1919
"desc.json": desc, "docs/readme.txt": []byte("Readme"), "docs/说明.txt": []byte("说明"),
20+
"docs/LICENSE-Voice.txt": []byte("License"), "docs/LICENSE.en.txt": []byte("English license"), "docs/许可证.txt": []byte("许可证"),
2021
"singer.json": []byte(`{"name":"Singer","avatar":"../../unchecked.png"}`),
2122
} {
2223
filename := filepath.Join(source, filepath.FromSlash(name))
@@ -50,13 +51,16 @@ func TestInfoCompleteMetadataFromArchiveAndInstallation(t *testing.T) {
5051
checkMetadata := func(info packagemanager.Info, expectedRoot string) {
5152
t.Helper()
5253
p := info.Package
53-
if p.Name["_"] != "Voice" || p.Vendor["_"] != "Vendor" || p.Description["_"] != "Description Voice" || len(p.Copyright) != 3 || p.Copyright["_"] != "" || p.Copyright["en"] != "Copyright Voice" || p.Copyright["zh-CN"] != "版权 Voice" || p.URL == nil || *p.URL != "https://example.test/Voice" {
54+
if p.Name["_"] != "Voice" || p.Vendor["_"] != "Vendor" || p.Description["_"] != "Description Voice" || p.URL == nil || *p.URL != "https://example.test/Voice" {
5455
t.Fatalf("incomplete typed metadata: %+v", p)
5556
}
57+
if len(p.License) != 3 || p.License["_"] != filepath.Join(expectedRoot, "docs", "LICENSE-Voice.txt") || p.License["en"] != filepath.Join(expectedRoot, "docs", "LICENSE.en.txt") || p.License["zh-CN"] != filepath.Join(expectedRoot, "docs", "许可证.txt") {
58+
t.Fatal("License paths were resolved in an incorrect context.", p.License, expectedRoot)
59+
}
5660
if len(p.Readme) != 2 || p.Readme["_"] != filepath.Join(expectedRoot, "docs", "readme.txt") || p.Readme["zh-CN"] != filepath.Join(expectedRoot, "docs", "说明.txt") {
5761
t.Fatal("readme resolved in the wrong context", p.Readme, expectedRoot)
5862
}
59-
if p.Manifest["extension"].(map[string]any)["text"] != "Voice" || p.Manifest["readme"].(map[string]any)["_"] != "docs/../docs/readme.txt" {
63+
if p.Manifest["extension"].(map[string]any)["text"] != "Voice" || p.Manifest["readme"].(map[string]any)["_"] != "docs/../docs/readme.txt" || p.Manifest["license"].(map[string]any)["_"] != "docs/../docs/LICENSE-Voice.txt" {
6064
t.Fatal("expanded declaration lost or rewritten", p.Manifest)
6165
}
6266
if _, exists := p.Manifest["vars"]; exists {
@@ -83,9 +87,12 @@ func TestInfoCompleteMetadataFromArchiveAndInstallation(t *testing.T) {
8387
if err != nil || !bytes.Equal(installedDesc, desc) {
8488
t.Fatal("metadata parsing rewrote desc.json", err)
8589
}
86-
for _, args := range [][]string{{"--file", archive}, {"--installed", "vendor/voice=1"}} {
87-
out := invoke(append([]string{"--language", "*", "info"}, args...)...)
88-
for _, text := range []string{"Copyright[_]: \n", "Copyright[en]: Copyright Voice", "Copyright[zh-CN]: 版权 Voice", "Readme[_]:", "Readme[zh-CN]:", "URL: https://example.test/Voice", "Avatar[_]: ../../unchecked.png"} {
90+
for _, test := range []struct {
91+
args []string
92+
root string
93+
}{{[]string{"--file", archive}, scratch}, {[]string{"--installed", "vendor/voice=1"}, installedRoot}} {
94+
out := invoke(append([]string{"--language", "*", "info"}, test.args...)...)
95+
for _, text := range []string{"License[_]: " + filepath.Join(test.root, "docs", "LICENSE-Voice.txt"), "License[en]: " + filepath.Join(test.root, "docs", "LICENSE.en.txt"), "License[zh-CN]: " + filepath.Join(test.root, "docs", "许可证.txt"), "Readme[_]:", "Readme[zh-CN]:", "URL: https://example.test/Voice", "Avatar[_]: ../../unchecked.png"} {
8996
if !strings.Contains(out, text) {
9097
t.Fatalf("info omitted %q:\n%s", text, out)
9198
}
@@ -97,7 +104,7 @@ func TestInfoCompleteMetadataFromArchiveAndInstallation(t *testing.T) {
97104
if err := json.Unmarshal([]byte(invoke("--json", "list")), &listing); err != nil || len(listing.Data) != 1 {
98105
t.Fatal("invalid installed index response", listing, err)
99106
}
100-
for _, field := range []string{"copyright", "readme", "url", "manifest"} {
107+
for _, field := range []string{"license", "readme", "url", "manifest"} {
101108
if _, exists := listing.Data[0][field]; exists {
102109
t.Errorf("full metadata leaked into the installed-package index: %s", field)
103110
}

‎internal/cli/output/text.go‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -141,11 +141,8 @@ func (r *Renderer) info(v packagemanager.Info) {
141141
r.field(" ", "CompatVersion", p.CompatVersion.String())
142142
r.field(" ", "FormatVersion", p.FormatVersion.String())
143143
r.field(" ", "RuntimeLevel", fmt.Sprint(p.RuntimeLevel))
144-
r.textField(" ", "Copyright", p.Copyright)
144+
r.textField(" ", "License", p.License)
145145
r.textField(" ", "Readme", p.Readme)
146-
if text, ok := packageinfo.TextValue(p.Manifest["license"]); ok {
147-
r.textField(" ", "License", text)
148-
}
149146
if p.URL != nil {
150147
r.field(" ", "URL", *p.URL)
151148
}

‎internal/cli/output/text_test.go‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,19 +58,23 @@ func TestTextInfoUsesCompleteTypedMetadata(t *testing.T) {
5858
r := styledRenderer(&out, io.Discard)
5959
r.Language = "*"
6060
p := packageinfo.PackageManifest{
61-
Copyright: packageinfo.Text{"_": "", "en": "Copyright Voice", "zh-CN": "版权"},
62-
Readme: packageinfo.Text{"_": "/voice/readme.txt", "zh-CN": "/voice/说明.txt"},
63-
URL: &url,
61+
License: packageinfo.Text{"_": "/voice/LICENSE.txt", "en": "/voice/LICENSE.en.txt", "zh-CN": "/voice/许可证.txt"},
62+
Readme: packageinfo.Text{"_": "/voice/readme.txt", "zh-CN": "/voice/说明.txt"},
63+
URL: &url,
64+
Manifest: map[string]any{"license": "LICENSE.txt"},
6465
}
6566
if err := r.Query(packagemanager.Info{Package: p}, nil); err != nil {
6667
t.Fatal(err)
6768
}
6869
plain := ansi.Strip(out.String())
69-
for _, text := range []string{" Copyright[_]: \n", " Copyright[en]: Copyright Voice\n", " Copyright[zh-CN]: 版权\n", " Readme[_]: /voice/readme.txt\n", " Readme[zh-CN]: /voice/说明.txt\n", " URL: " + url + "\n"} {
70+
for _, text := range []string{" License[_]: /voice/LICENSE.txt\n", " License[en]: /voice/LICENSE.en.txt\n", " License[zh-CN]: /voice/许可证.txt\n", " Readme[_]: /voice/readme.txt\n", " Readme[zh-CN]: /voice/说明.txt\n", " URL: " + url + "\n"} {
7071
if !strings.Contains(plain, text) {
7172
t.Fatalf("typed metadata missing from info: %q in:\n%s", text, plain)
7273
}
7374
}
75+
if strings.Count(plain, "License[_]:") != 1 {
76+
t.Fatal("The license field was rendered more than once.", plain)
77+
}
7478
})
7579
}
7680
}

0 commit comments

Comments
 (0)