Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,7 @@ type ParseResult struct {
Dependencies []Dependency
Declarations []Declaration
Sources []Source // Ordered manifest-level source declarations
Scripts map[string][]string // Hook or task names with ordered commands or paths
}
```

Expand All @@ -339,6 +340,23 @@ type ParseResult struct {

`Digest` contains a file-level verification value when the format defines one. For `Chart.lock`, it covers the dependency declarations from `Chart.yaml` and is separate from each dependency's `Integrity` value.

`Scripts` contains declared commands, callbacks or script paths, including named tasks as well as install hooks. For example, `result.Scripts["postinstall"]` holds the command from npm's `scripts.postinstall`. Lists preserve declaration order; empty declarations are omitted.

| Format | Script declarations |
| --- | --- |
| `package.json`, `composer.json`, `shard.yml`, .NET `project.json` | Entries in `scripts` |
| `Cargo.toml` | `package.build`, under `build`; explicit `true` produces `build.rs`, `false` produces no entry |
| `.gemspec` | Literal `extensions` arrays, `%w` lists and `<<` additions |
| `.podspec` | Quoted or heredoc `prepare_command` |
| `deno.json` | Task strings and task objects' `command` values, under the task name |
| `dub.json` | Build, generate and run commands; platform suffixes are retained, with `configurations/<name>/`, `buildTypes/<name>/` and `subPackages/<name>/` prefixes for nested declarations |
| `.csproj`, `.vbproj`, `.fsproj` | `PreBuildEvent`, `PostBuildEvent` and direct `Exec` commands under `Target/<name>` |
| `pyproject.toml` | Poetry's build script under `tool.poetry.build`, and PDM's explicit `tool.pdm.build.custom-hook` |
| OPAM | `build`, `install`, `remove`, `run-test`, `build-test` and `build-doc`, each as a raw field value retaining arguments and filters |
| `APKBUILD`, `PKGBUILD` | Unindented `install` assignments, under `install`, without shell expansion |

Parsing does not execute scripts, evaluate conditions or follow script paths. Ruby string bodies retain source escapes and heredoc indentation. Dynamic Ruby expressions and arbitrary build code are not resolved. A missing entry does not establish that a package has no install hooks: files such as an undeclared `build.rs` or `deps/build.jl` require separate filesystem discovery.

Chef cookbook and Vagrant box PURLs remain empty while `chef` and `vagrant`
are only candidate Package URL types without accepted name and namespace rules.

Expand Down
4 changes: 3 additions & 1 deletion internal/alpine/alpine.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ func (p *apkbuildParser) Parse(filename string, content []byte) (*core.Result, e
deps = append(deps, dep)
}

return &core.Result{Name: vars["pkgname"], Version: vars["pkgver"], Dependencies: deps}, nil
var scripts map[string][]string
core.AddScript(&scripts, "install", strings.Fields(core.ShellInstall(string(content)))...)
return &core.Result{Name: vars["pkgname"], Version: vars["pkgver"], Dependencies: deps, Scripts: scripts}, nil
}

func parseApkbuildVars(content string) map[string]string {
Expand Down
4 changes: 3 additions & 1 deletion internal/arch/arch.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ func (p *pkgbuildParser) Parse(filename string, content []byte) (*core.Result, e
deps = append(deps, dep)
}

return &core.Result{Name: vars["pkgname"], Version: vars["pkgver"], Dependencies: deps}, nil
var scripts map[string][]string
core.AddScript(&scripts, "install", core.ShellInstall(string(content)))
return &core.Result{Name: vars["pkgname"], Version: vars["pkgver"], Dependencies: deps, Scripts: scripts}, nil
}

func parsePkgbuildVars(content string) map[string]string {
Expand Down
11 changes: 11 additions & 0 deletions internal/cargo/cargo.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ func (p *cargoTomlParser) Parse(filename string, content []byte) (*core.Result,
Version inheritableString `toml:"version"`
License inheritableString `toml:"license"`
LicenseFile inheritableString `toml:"license-file"`
Build any `toml:"build"`
} `toml:"package"`
Dependencies map[string]any `toml:"dependencies"`
DevDependencies map[string]any `toml:"dev-dependencies"`
Expand Down Expand Up @@ -87,11 +88,21 @@ func (p *cargoTomlParser) Parse(filename string, content []byte) (*core.Result,
if cargo.Package.License != "" {
licenses = []string{string(cargo.Package.License)}
}
var scripts map[string][]string
switch build := cargo.Package.Build.(type) {
case string:
core.AddScript(&scripts, "build", build)
case bool:
if build {
core.AddScript(&scripts, "build", "build.rs")
}
}
return &core.Result{
Name: pkgName,
Version: string(cargo.Package.Version),
Licenses: licenses,
LicenseFile: string(cargo.Package.LicenseFile),
Scripts: scripts,
Dependencies: filtered,
Declarations: declarations,
}, nil
Expand Down
33 changes: 33 additions & 0 deletions internal/cocoapods/cocoapods.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,8 +176,40 @@ var (
podspecLicenseHashRegex = regexp.MustCompile(`(?s)\.license\s*=\s*\{([^}]*)\}`)
podspecLicenseTypeRegex = regexp.MustCompile(`(?::type|["']type["']|\btype)\s*(?:=>|:)\s*["']([^"']+)["']`)
podspecLicenseFileRegex = regexp.MustCompile(`(?::file|["']file["']|\bfile)\s*(?:=>|:)\s*["']([^"']+)["']`)
podspecPrepareRegex = regexp.MustCompile(`(?m)^[\t ]*\w+\.prepare_command[\t ]*=[\t ]*(?:("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')|<<[-~]?["']?(\w+)["']?)[\t ]*(?:#.*)?\r?$`)
)

func podspecScripts(text string) map[string][]string {
var scripts map[string][]string
for {
match := podspecPrepareRegex.FindStringSubmatchIndex(text)
if match == nil {
break
}
literal := ""
if match[2] >= 0 {
literal = text[match[2]:match[3]]
}
delimiter := ""
if match[4] >= 0 {
delimiter = text[match[4]:match[5]]
}
text = text[match[1]:]
if literal != "" {
core.AddScript(&scripts, "prepare_command", literal[1:len(literal)-1])
continue
}
text = strings.TrimPrefix(text, "\n")
end := regexp.MustCompile(`(?m)^[\t ]*` + regexp.QuoteMeta(delimiter) + `[\t ]*\r?$`).FindStringIndex(text)
if end == nil {
break
}
core.AddScript(&scripts, "prepare_command", text[:end[0]])
text = text[end[1]:]
}
return scripts
}

func (p *podspecParser) Parse(filename string, content []byte) (*core.Result, error) {
var deps []core.Dependency
text := string(content)
Expand Down Expand Up @@ -222,6 +254,7 @@ func (p *podspecParser) Parse(filename string, content []byte) (*core.Result, er
Version: selfVersion,
Licenses: licenses,
LicenseFile: licenseFile,
Scripts: podspecScripts(text),
Dependencies: deps,
}, nil
}
2 changes: 2 additions & 0 deletions internal/composer/composer.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type composerJSON struct {
Name string `json:"name"`
Version string `json:"version"`
License any `json:"license"`
Scripts map[string]any `json:"scripts"`
Require map[string]string `json:"require"`
RequireDev map[string]string `json:"require-dev"`
}
Expand Down Expand Up @@ -64,6 +65,7 @@ func (p *composerJSONParser) Parse(filename string, content []byte) (*core.Resul
Name: composer.Name,
Version: composer.Version,
Licenses: composerLicenses(composer.License),
Scripts: core.StringScripts(composer.Scripts),
Dependencies: deps,
}, nil
}
Expand Down
32 changes: 32 additions & 0 deletions internal/core/scripts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package core

// StringScripts accepts string commands and ordered lists of string commands.
func StringScripts(values map[string]any) map[string][]string {
var scripts map[string][]string
for name, value := range values {
switch commands := value.(type) {
case string:
AddScript(&scripts, name, commands)
case []any:
for _, command := range commands {
if text, ok := command.(string); ok {
AddScript(&scripts, name, text)
}
}
}
}
return scripts
}

// AddScript preserves command order and omits empty declarations.
func AddScript(scripts *map[string][]string, name string, commands ...string) {
for _, command := range commands {
if command == "" {
continue
}
if *scripts == nil {
*scripts = make(map[string][]string)
}
(*scripts)[name] = append((*scripts)[name], command)
}
}
19 changes: 19 additions & 0 deletions internal/core/shell.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package core

import "regexp"

var shellInstallPattern = regexp.MustCompile(`(?m)^install=(?:"([^"\\]*)"|'([^']*)'|([^\s#;'"\\]+))[\t ]*(?:#.*)?\r?$`)

// ShellInstall extracts a top-level install assignment without shell expansion.
func ShellInstall(content string) string {
match := shellInstallPattern.FindStringSubmatch(content)
if match == nil {
return ""
}
for _, value := range match[1:] {
if value != "" {
return value
}
}
return ""
}
2 changes: 2 additions & 0 deletions internal/core/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ type Result struct {
// These entries are configuration, not evidence that any dependency was
// resolved from a particular source.
Sources []Source
// Scripts maps declared hook or task names to ordered commands or script paths.
Scripts map[string][]string
}

// Parser is the interface implemented by all manifest parsers.
Expand Down
3 changes: 2 additions & 1 deletion internal/crystal/crystal.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type shardYMLParser struct{}
type shardYML struct {
Name string `yaml:"name"`
Version string `yaml:"version"`
Scripts map[string]any `yaml:"scripts"`
Dependencies map[string]shardDep `yaml:"dependencies"`
DevelopmentDependencies map[string]shardDep `yaml:"development_dependencies"`
}
Expand Down Expand Up @@ -78,7 +79,7 @@ func (p *shardYMLParser) Parse(filename string, content []byte) (*core.Result, e
})
}

return &core.Result{Name: shard.Name, Version: shard.Version, Dependencies: deps}, nil
return &core.Result{Name: shard.Name, Version: shard.Version, Dependencies: deps, Scripts: core.StringScripts(shard.Scripts)}, nil
}

func getShardVersion(dep shardDep) string {
Expand Down
47 changes: 46 additions & 1 deletion internal/dub/dub.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ package dub
import (
"encoding/json"
"github.com/git-pkgs/manifests/internal/core"
"net/url"
"regexp"
"strconv"
"strings"
)

Expand Down Expand Up @@ -53,7 +55,50 @@ func (p *dubJSONParser) Parse(filename string, content []byte) (*core.Result, er
})
}

return &core.Result{Name: dub.Name, Version: dub.Version, Dependencies: deps}, nil
var recipe map[string]any
if err := json.Unmarshal(content, &recipe); err != nil {
return nil, &core.ParseError{Filename: filename, Err: err}
}
var scripts map[string][]string
collectDubScripts(&scripts, "", recipe)
return &core.Result{Name: dub.Name, Version: dub.Version, Dependencies: deps, Scripts: scripts}, nil
}

func collectDubScripts(scripts *map[string][]string, prefix string, recipe map[string]any) {
for name, value := range recipe {
hook, _, _ := strings.Cut(name, "-")
if isDubHook(hook) {
for _, commands := range core.StringScripts(map[string]any{name: value}) {
core.AddScript(scripts, prefix+name, commands...)
}
}
}
for _, group := range []string{"configurations", "subPackages"} {
entries, _ := recipe[group].([]any)
for i, entry := range entries {
if properties, ok := entry.(map[string]any); ok {
name, _ := properties["name"].(string)
if name == "" {
name = strconv.Itoa(i)
}
collectDubScripts(scripts, prefix+group+"/"+url.PathEscape(name)+"/", properties)
}
}
}
buildTypes, _ := recipe["buildTypes"].(map[string]any)
for name, entry := range buildTypes {
if properties, ok := entry.(map[string]any); ok {
collectDubScripts(scripts, prefix+"buildTypes/"+url.PathEscape(name)+"/", properties)
}
}
}

func isDubHook(name string) bool {
switch name {
case "preGenerateCommands", "postGenerateCommands", "preBuildCommands", "postBuildCommands", "preRunCommands", "postRunCommands":
return true
}
return false
}

// dubSDLParser parses dub.sdl files.
Expand Down
26 changes: 23 additions & 3 deletions internal/gem/rubygems.go
Original file line number Diff line number Diff line change
Expand Up @@ -391,11 +391,30 @@ func (p *gemfileLockParser) Parse(filename string, content []byte) (*core.Result
type gemspecParser struct{}

var (
gemspecLicenseRegex = regexp.MustCompile(`(?m)\.license\s*=\s*["']([^"']+)["']`)
gemspecLicensesRegex = regexp.MustCompile(`(?s)\.licenses\s*=\s*\[([^\]]*)\]`)
rubyQuotedRegex = regexp.MustCompile(`["']([^"']+)["']`)
gemspecLicenseRegex = regexp.MustCompile(`(?m)\.license\s*=\s*["']([^"']+)["']`)
gemspecLicensesRegex = regexp.MustCompile(`(?s)\.licenses\s*=\s*\[([^\]]*)\]`)
rubyQuotedRegex = regexp.MustCompile(`["']([^"']+)["']`)
gemspecExtensionsRegex = regexp.MustCompile(`(?m)^[\t ]*\w+\.extensions[\t ]*(?:=|\+=|<<)[\t ]*(\[(?:\s*` + rubyScriptLiteral + `\s*,?)*\s*\]|%w\[[^\]\\]*\]|%w\([^\)\\]*\)|` + rubyScriptLiteral + `)[\t ]*(?:#.*)?\r?$`)
gemspecScriptStringRegex = regexp.MustCompile(rubyScriptLiteral)
)

const rubyScriptLiteral = `(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')`

func gemspecScripts(text string) map[string][]string {
var scripts map[string][]string
for _, match := range gemspecExtensionsRegex.FindAllStringSubmatch(text, -1) {
value := match[1]
if strings.HasPrefix(value, "%w") {
core.AddScript(&scripts, "extensions", strings.Fields(value[len("%w["):len(value)-1])...)
continue
}
for _, literal := range gemspecScriptStringRegex.FindAllString(value, -1) {
core.AddScript(&scripts, "extensions", literal[1:len(literal)-1])
}
}
return scripts
}

// extractGemspecAttr extracts a string literal from lines like `s.name = "foo"`
// or `spec.version = 'foo'`. Returns empty when the RHS is not a string literal
// (e.g. a constant reference).
Expand Down Expand Up @@ -521,6 +540,7 @@ func (p *gemspecParser) Parse(filename string, content []byte) (*core.Result, er
Name: selfName,
Version: selfVersion,
Licenses: licenses,
Scripts: gemspecScripts(text),
Dependencies: deps,
}, nil
}
11 changes: 10 additions & 1 deletion internal/npm/deno.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type denoJSON struct {
Name string `json:"name"`
Version string `json:"version"`
Imports map[string]string `json:"imports"`
Tasks map[string]any `json:"tasks"`
}

func (p *denoJSONParser) Parse(filename string, content []byte) (*core.Result, error) {
Expand All @@ -41,7 +42,15 @@ func (p *denoJSONParser) Parse(filename string, content []byte) (*core.Result, e
}
}

return &core.Result{Name: deno.Name, Version: deno.Version, Dependencies: deps}, nil
scripts := core.StringScripts(deno.Tasks)
for name, task := range deno.Tasks {
if properties, ok := task.(map[string]any); ok {
if command, ok := properties["command"].(string); ok {
core.AddScript(&scripts, name, command)
}
}
}
return &core.Result{Name: deno.Name, Version: deno.Version, Dependencies: deps, Scripts: scripts}, nil
}

// denoLockParser parses deno.lock files.
Expand Down
2 changes: 2 additions & 0 deletions internal/npm/npm.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type packageJSON struct {
Version string `json:"version"`
License any `json:"license"`
Licenses []npmLicense `json:"licenses"`
Scripts map[string]any `json:"scripts"`
Dependencies map[string]any `json:"dependencies"`
DevDependencies map[string]any `json:"devDependencies"`
OptionalDependencies map[string]any `json:"optionalDependencies"`
Expand Down Expand Up @@ -56,6 +57,7 @@ func (p *npmPackageJSONParser) Parse(filename string, content []byte) (*core.Res
Name: pkg.Name,
Version: pkg.Version,
Licenses: npmLicenses(pkg.License, pkg.Licenses),
Scripts: core.StringScripts(pkg.Scripts),
Dependencies: deps,
Declarations: declarations,
}, nil
Expand Down
Loading
Loading