From 4162379b20fa0c767d712c97c45d5fb51a02f8b3 Mon Sep 17 00:00:00 2001 From: Fabio Bonelli Date: Sat, 13 Jun 2026 19:30:26 +0200 Subject: [PATCH 1/5] feat: enable parsing of publiccode.yml v1 --- parser.go | 6 +- publiccode.go | 9 +- .../invalid/publiccodeYmlVersion_invalid.yml | 4 +- testdata/v1/invalid/it_IT_duplicated.yml | 123 ----- testdata/v1/valid/valid.yml | 2 + v0_test.go | 2 +- v1_test.go | 458 +++++++++++++++++- 7 files changed, 456 insertions(+), 148 deletions(-) delete mode 100644 testdata/v1/invalid/it_IT_duplicated.yml diff --git a/parser.go b/parser.go index 1a487739..41a439f8 100644 --- a/parser.go +++ b/parser.go @@ -249,16 +249,14 @@ func (p *Parser) parseStream(in io.Reader, fileURL *url.URL) (PublicCode, error) var ve ValidationResults - if slices.Contains(SupportedVersions, version) && version != "0" && !strings.HasPrefix(version, "0.5") { - latestVersion := SupportedVersions[len(SupportedVersions)-1] + if version != "0" && version != "1" && !strings.HasPrefix(version, "0.5") { line, column := getPositionInFile("publiccodeYmlVersion", file) ve = append(ve, ValidationWarning{ Key: "publiccodeYmlVersion", Description: fmt.Sprintf( - "v%s is not the latest version, use '0'. Parsing this file as v%s.", + "v%s is not the latest version, use '0'. Parsing this file as v0.5.", version, - latestVersion, ), Line: line, Column: column, diff --git a/publiccode.go b/publiccode.go index b175f329..d4c647bd 100644 --- a/publiccode.go +++ b/publiccode.go @@ -1,7 +1,14 @@ package publiccode // SupportedVersions lists the publiccode.yml versions this parser supports. -var SupportedVersions = []string{"0", "0.2", "0.2.0", "0.2.1", "0.2.2", "0.3", "0.3.0", "0.4", "0.4.0", "0.5.0", "0.5"} +var SupportedVersions = []string{ + "0", + "0.2", "0.2.0", "0.2.1", "0.2.2", + "0.3", "0.3.0", + "0.4", "0.4.0", + "0.5.0", "0.5", + "1", +} type PublicCode interface { Version() uint diff --git a/testdata/v0/invalid/publiccodeYmlVersion_invalid.yml b/testdata/v0/invalid/publiccodeYmlVersion_invalid.yml index c061b3d3..d1f24f36 100644 --- a/testdata/v0/invalid/publiccodeYmlVersion_invalid.yml +++ b/testdata/v0/invalid/publiccodeYmlVersion_invalid.yml @@ -1,5 +1,5 @@ -# Should NOT validate: version 1 doesn't exist yet -publiccodeYmlVersion: "1" +# Should NOT validate: version 2 doesn't exist yet +publiccodeYmlVersion: "2" name: Medusa url: "https://github.com/italia/developers.italia.it.git" diff --git a/testdata/v1/invalid/it_IT_duplicated.yml b/testdata/v1/invalid/it_IT_duplicated.yml deleted file mode 100644 index 26674f53..00000000 --- a/testdata/v1/invalid/it_IT_duplicated.yml +++ /dev/null @@ -1,123 +0,0 @@ -publiccodeYmlVersion: "1" - -name: Medusa -applicationSuite: MegaProductivitySuite -url: "https://github.com/italia/developers.italia.it.git" -landingURL: "https://developers.italia.it" - -isBasedOn: "https://github.com/italia/developers.italia.it.git" -softwareVersion: "1.0" -releaseDate: 2017-04-15 - -platforms: - - android - - ios - -categories: - - cloud-management - -usedBy: - - Comune di Firenze - - Comune di Roma - -roadmap: "https://designers.italia.it/roadmap/" - -developmentStatus: development - -softwareType: "standalone/other" - -intendedAudience: - countries: - - IT - - DE - unsupportedCountries: - - US - -description: - en: - localisedName: Medusa - shortDescription: "A really interesting software." - longDescription: > - Very long description of this software, also split - on multiple rows. You should note what the software - is and why one should need it. This is 158 characters. - Very long description of this software, also split - on multiple rows. You should note what the software - is and why one should need it. This is 316 characters. - Very long description of this software, also split - on multiple rows. You should note what the software - is and why one should need it. This is 474 characters. - Very long description of this software, also split - on multiple rows. You should note what the software - is and why one should need it. This is 632 characters. - - documentation: "https://docs.italia.it" - apiDocumentation: "https://developers.italia.it/it/api" - - features: - - Very important feature - - Will run without a problem - - Has zero bugs - - Solves all the problems of the world - videos: - - https://www.youtube.com/watch?v=RaHmGbBOP84 - awards: - - 1st Price Software of the year - -legal: - license: AGPL-3.0-or-later - mainCopyrightOwner: City of Chicago - -maintenance: - type: "community" - contacts: - - email: mrossi@cittametropolitana.it - name: Mario Rossi - phone: "+390412501953" - -localisation: - localisationReady: true - availableLanguages: - - en - - it - - fr - - de - -dependsOn: - open: - - name: MySQL - versionMin: "1.1" - versionMax: "1.3" - optional: true - - name: PostgreSQL - version: "3.2" - optional: true - proprietary: - - name: Oracle - versionMin: "11.4" - hardware: - - name: NFC Reader - optional: true - - name: NFC Reader1 - optional: true - - name: NFC Reader2 - optional: true - -# Should NOT validate: 'it' (deprecated) and 'IT' present at the same time -IT: - countryExtensionVersion: "1.0" - - piattaforme: - spid: false - pagopa: false - cie: false - anpr: false - -it: - countryExtensionVersion: "1.0" - - piattaforme: - spid: false - pagopa: false - cie: false - anpr: false diff --git a/testdata/v1/valid/valid.yml b/testdata/v1/valid/valid.yml index 54aa8596..5841eb08 100644 --- a/testdata/v1/valid/valid.yml +++ b/testdata/v1/valid/valid.yml @@ -18,6 +18,8 @@ description: that the v1 parser works correctly. It needs to be at least 150 characters long to pass validation checks in the publiccode.yml parser implementation. This is enough text now. + features: + - Test feature legal: license: MIT diff --git a/v0_test.go b/v0_test.go index 9d719774..cdaae34e 100644 --- a/v0_test.go +++ b/v0_test.go @@ -110,7 +110,7 @@ func TestInvalidTestcasesV0(t *testing.T) { "publiccodeYmlVersion_invalid.yml": ValidationResults{ ValidationError{ "publiccodeYmlVersion", - "unsupported version: '1'. Supported versions: 0, 0.2, 0.2.0, 0.2.1, 0.2.2, 0.3, 0.3.0, 0.4, 0.4.0, 0.5.0, 0.5", + "unsupported version: '2'. Supported versions: 0, 0.2, 0.2.0, 0.2.1, 0.2.2, 0.3, 0.3.0, 0.4, 0.4.0, 0.5.0, 0.5, 1", 0, 0, }, diff --git a/v1_test.go b/v1_test.go index e08b22c3..1e69d5d9 100644 --- a/v1_test.go +++ b/v1_test.go @@ -1,33 +1,457 @@ package publiccode import ( + "path" "path/filepath" + "slices" "testing" ) +// Test v1 valid YAML testcases (testdata/v1/valid/). +func TestValidTestcasesV1(t *testing.T) { + checkValidFiles("testdata/v1/valid/*.yml", t) +} + +// Test v1 invalid YAML testcases (testdata/v1/invalid/). func TestInvalidTestcasesV1(t *testing.T) { - dirs := []string{"testdata/v1/invalid/", - "testdata/v1/invalid_valid_in_v0/", - "testdata/v1/invalid_valid_with_warnings_in_v0/", + expected := map[string]error{ + "applicationSuite_wrong_type.yml": ValidationResults{ + ValidationError{"applicationSuite", "wrong type for this field", 4, 1}, + }, + "categories_invalid.yml": ValidationResults{ + ValidationError{"categories[0]", "categories[0] must be a valid category (see https://github.com/publiccodeyml/publiccode.yml/blob/main/docs/standard/categories-list.rst)", 13, 5}, + }, + "dependsOn_open_name_wrong_type.yml": ValidationResults{ + ValidationError{"dependsOn.open[0]", "wrong type for this field", 56, 1}, + }, + "dependsOn_open_optional_wrong_type.yml": ValidationResults{ + ValidationError{"dependsOn.open[0].optional", "wrong type for this field", 57, 1}, + }, + "dependsOn_open_versionMax_wrong_type.yml": ValidationResults{ + ValidationError{"dependsOn.open[0].versionMax", "wrong type for this field", 57, 1}, + }, + "dependsOn_open_versionMin_wrong_type.yml": ValidationResults{ + ValidationError{"dependsOn.open[0].versionMin", "wrong type for this field", 57, 1}, + }, + "dependsOn_open_version_wrong_type.yml": ValidationResults{ + ValidationError{"dependsOn.open[0].version", "wrong type for this field", 57, 1}, + }, + "description_en_apiDocumentation_invalid.yml": ValidationResults{ + ValidationError{"description.en.apiDocumentation", "apiDocumentation must be an HTTP URL", 41, 5}, + ValidationError{"description.en.apiDocumentation", "'abc' not reachable: missing URL scheme", 41, 5}, + }, + "description_en_apiDocumentation_wrong_type.yml": ValidationResults{ + ValidationError{"description.en.apiDocumentation", "wrong type for this field", 43, 1}, + ValidationError{"description", "description must contain more than 0 items", 20, 1}, + }, + "description_en_awards_wrong_type.yml": ValidationResults{ + ValidationError{"description.en.awards", "wrong type for this field", 40, 1}, + ValidationError{"description", "description must contain more than 0 items", 18, 1}, + }, + "description_en_documentation_invalid.yml": ValidationResults{ + ValidationError{"description.en.documentation", "documentation must be an HTTP URL", 25, 5}, + ValidationError{"description.en.documentation", "'not_a_url' not reachable: missing URL scheme", 25, 5}, + }, + "description_en_documentation_wrong_type.yml": ValidationResults{ + ValidationError{"description.en.documentation", "wrong type for this field", 25, 1}, + ValidationError{"description", "description must contain more than 0 items", 20, 1}, + }, + "description_en_features_empty.yml": ValidationResults{ + ValidationError{"description.en.features", "features must contain more than 0 items", 39, 5}, + }, + "description_en_features_missing.yml": ValidationResults{ + ValidationError{"description.en.features", "features must contain more than 0 items", 22, 5}, + }, + "description_en_gb_invalid_bcp47.yml": ValidationResults{ + ValidationError{"description", "description must be a valid BCP 47 language", 18, 1}, + }, + "description_en_genericName_too_long.yml": ValidationResults{ + ValidationError{"description.en.genericName", "unknown field \"genericName\"", 22, 1}, + ValidationError{"description", "description must contain more than 0 items", 18, 1}, + }, + "description_en_localisedName_wrong_type.yml": ValidationResults{ + ValidationError{"description.en.localisedName", "wrong type for this field", 21, 1}, + ValidationError{"description", "description must contain more than 0 items", 18, 1}, + }, + "description_en_longDescription_missing.yml": ValidationResults{ + ValidationError{"description.en.longDescription", "longDescription is a required field", 20, 5}, + }, + "description_en_longDescription_too_long.yml": ValidationResults{ + ValidationError{"description.en.longDescription", "longDescription must be a maximum of 10000 characters in length", 27, 5}, + }, + "description_en_longDescription_too_short.yml": ValidationResults{ + ValidationError{"description.en.longDescription", "longDescription must be at least 150 characters in length", 27, 5}, + }, + "description_en_longDescription_too_short_grapheme_clusters.yml": ValidationResults{ + ValidationError{"description.en.longDescription", "longDescription must be at least 150 characters in length", 28, 5}, + }, + "description_en_screenshots_missing_file.yml": ValidationResults{ + ValidationError{"description.en.screenshots[0]", "'no_such_file.png' is not an image: no such file: " + cwd + "/testdata/v1/invalid/no_such_file.png", 42, 9}, + }, + "description_en_shortDescription_missing.yml": ValidationResults{ + ValidationError{"description.en.shortDescription", "shortDescription is a required field", 20, 5}, + }, + "description_en_videos_invalid.yml": ValidationResults{ + ValidationError{"description.en.videos[0]", "videos[0] must be an HTTP URL", 41, 9}, + ValidationError{"description.en.videos[0]", "'ABC' is not a valid video URL supporting oEmbed: invalid oEmbed link: ABC", 41, 9}, + }, + "description_en_videos_invalid_oembed.yml": ValidationResults{ + ValidationError{"description.en.videos[0]", "'https://google.com' is not a valid video URL supporting oEmbed: invalid oEmbed link: https://google.com", 41, 9}, + }, + "description_invalid_language.yml": ValidationResults{ + ValidationError{"description", "description must be a valid BCP 47 language", 18, 1}, + }, + "developmentStatus_invalid.yml": ValidationResults{ + ValidationError{"developmentStatus", "developmentStatus must be one of the following: \"concept\", \"development\", \"beta\", \"stable\" or \"obsolete\"", 16, 1}, + }, + "developmentStatus_missing.yml": ValidationResults{ + ValidationError{"developmentStatus", "developmentStatus is a required field", 1, 1}, + }, + "developmentStatus_wrong_type.yml": ValidationResults{ + ValidationError{"developmentStatus", "wrong type for this field", 16, 1}, + ValidationError{"developmentStatus", "developmentStatus is a required field", 16, 1}, + }, + "fundedBy_uri_missing.yml": ValidationResults{ + ValidationError{"fundedBy[0].uri", "uri is a required field", 18, 5}, + }, + "fundedBy_uri_wrong_italian_pa.yml": ValidationResults{ + ValidationError{"fundedBy[0].uri", "uri is not a valid URI", 20, 5}, + }, + "fundedBy_uri_wrong_italian_pa2.yml": ValidationResults{ + ValidationError{"fundedBy[0].uri", "uri must be a valid Italian Public Administration Code (iPA) with format 'urn:x-italian-pa:[codiceIPA]' (see https://github.com/publiccodeyml/italian-organizations-ipa-vocabulary)", 19, 5}, + }, + "fundedBy_wrong_type.yml": ValidationResults{ + ValidationError{"fundedBy.name", "wrong type for this field", 18, 1}, + }, + "fundedBy_wrong_uri.yml": ValidationResults{ + ValidationError{"fundedBy[0].uri", "uri is not a valid URI", 19, 5}, + }, + "inputTypes_invalid.yml": ValidationResults{ + ValidationError{"inputTypes", "unknown field \"inputTypes\"", 14, 1}, + }, + "inputTypes_wrong_type.yml": ValidationResults{ + ValidationError{"inputTypes", "unknown field \"inputTypes\"", 14, 1}, + }, + "intendedAudience_countries_invalid_country.yml": ValidationResults{ + ValidationError{"intendedAudience.countries[2]", "countries[2] must be a valid ISO 3166-1 alpha-2 two-letter country code", 22, 7}, + }, + "intendedAudience_countries_invalid_iso_3166_1_alpha_2.yml": ValidationResults{ + ValidationError{"intendedAudience.countries[2]", "countries[2] must be a valid ISO 3166-1 alpha-2 two-letter country code", 22, 7}, + }, + "intendedAudience_countries_wrong_type.yml": ValidationResults{ + ValidationError{"intendedAudience.countries", "wrong type for this field", 19, 1}, + }, + "intendedAudience_scope_invalid_scope.yml": ValidationResults{ + ValidationError{"intendedAudience.scope[0]", "scope[0] must be a valid scope (see https://github.com/publiccodeyml/publiccode.yml/blob/main/docs/standard/scope-list.rst)", 20, 9}, + }, + "intendedAudience_scope_wrong_type.yml": ValidationResults{ + ValidationError{"intendedAudience.scope", "wrong type for this field", 19, 1}, + }, + "intendedAudience_unsupportedCountries_invalid_country.yml": ValidationResults{ + ValidationError{"intendedAudience.unsupportedCountries[0]", "unsupportedCountries[0] must be a valid ISO 3166-1 alpha-2 two-letter country code", 20, 7}, + }, + "intendedAudience_unsupportedCountries_wrong_type.yml": ValidationResults{ + ValidationError{"intendedAudience.unsupportedCountries", "wrong type for this field", 19, 1}, + }, + "intendedAudience_wrong_type.yml": ValidationResults{ + ValidationError{"intendedAudience", "wrong type for this field", 18, 1}, + }, + "isBasedOn_bad_url_array.yml": ValidationResults{ + ValidationError{"isBasedOn[1]", "isBasedOn[1] must be a valid URL", 11, 5}, + }, + "isBasedOn_bad_url_string.yml": ValidationResults{ + ValidationError{"isBasedOn[0]", "isBasedOn[0] must be a valid URL", 9, 1}, + }, + "isBasedOn_wrong_type.yml": ValidationResults{ + ValidationError{"isBasedOn.foobar", "wrong type for this field", 10, 1}, + }, + "it_countryExtensionVersion_invalid.yml": ValidationResults{ + ValidationError{"IT", "unknown field \"IT\"", 11, 1}, + }, + "it_riuso_codiceIPA_invalid.yml": ValidationResults{ + ValidationError{"IT", "unknown field \"IT\"", 53, 1}, + }, + "it_wrong_case.yml": ValidationResults{ + ValidationError{"It", "unknown field \"It\"", 107, 1}, + }, + "landingURL_invalid.yml": ValidationResults{ + ValidationError{"IT", "unknown field \"IT\"", 120, 1}, + ValidationError{"landingURL", "landingURL must be an HTTP URL", 8, 1}, + ValidationError{"landingURL", "'???' not reachable: missing URL scheme", 8, 1}, + }, + "landingURL_wrong_type.yml": ValidationResults{ + ValidationError{"landingURL", "wrong type for this field", 8, 1}, + }, + "legal_authorsFile_missing_file.yml": ValidationResults{ + ValidationError{"legal.authorsFile", "unknown field \"authorsFile\"", 42, 1}, + ValidationError{"legal.license", "license is a required field", 40, 3}, + }, + "legal_license_invalid.yml": ValidationResults{ + ValidationError{"legal.license", "license must be a valid license (see https://spdx.org/licenses)", 42, 3}, + }, + "legal_license_missing.yml": ValidationResults{ + ValidationError{"legal.license", "license is a required field", 41, 3}, + }, + "legal_missing.yml": ValidationResults{ + ValidationError{"legal.license", "license is a required field", 0, 0}, + }, + "legal_wrong_type.yml": ValidationResults{ + ValidationError{"legal", "wrong type for this field", 46, 1}, + ValidationError{"legal.license", "license is a required field", 46, 8}, + }, + "localisation_availableLanguages_empty.yml": ValidationResults{ + ValidationError{"localisation.availableLanguages", "availableLanguages must contain more than 0 items", 52, 3}, + }, + "localisation_availableLanguages_invalid.yml": ValidationResults{ + ValidationError{"localisation.availableLanguages[0]", "availableLanguages[0] must be a valid BCP 47 language", 53, 8}, + }, + "localisation_availableLanguages_invalid_bcp47.yml": ValidationResults{ + ValidationError{"localisation.availableLanguages[0]", "availableLanguages[0] must be a valid BCP 47 language", 54, 8}, + }, + "localisation_availableLanguages_missing.yml": ValidationResults{ + ValidationError{"localisation.availableLanguages", "availableLanguages is a required field", 50, 3}, + }, + "localisation_localisationReady_missing.yml": ValidationResults{ + ValidationError{"localisation.localisationReady", "localisationReady is a required field", 52, 3}, + }, + "logo_absolute_path.yml": ValidationResults{ + ValidationError{"logo", "is an absolute path. Only relative paths or HTTP(s) URLs allowed", 18, 1}, + }, + "logo_file_scheme.yml": ValidationResults{ + ValidationError{"logo", "is a file:// URL. Only relative paths or HTTP(s) URLs allowed", 18, 1}, + }, + "logo_file_scheme2.yml": ValidationResults{ + ValidationError{"logo", "is a file:// URL. Only relative paths or HTTP(s) URLs allowed", 18, 1}, + }, + "logo_file_scheme3.yml": ValidationResults{ + ValidationError{"logo", "is a file:// URL. Only relative paths or HTTP(s) URLs allowed", 18, 1}, + }, + "logo_missing_file.yml": ValidationResults{ + ValidationError{"logo", "no such file: " + cwd + "/testdata/v1/invalid/no_such_file.png", 18, 1}, + }, + "logo_missing_url.yml": ValidationResults{ + ValidationError{"logo", "HTTP GET failed for https://google.com/no_such_file.png: not found", 18, 1}, + }, + "logo_unsupported_extension.yml": ValidationResults{ + ValidationError{"logo", "invalid file extension for: " + cwd + "/testdata/v1/invalid/logo.mpg", 18, 1}, + }, + "logo_wrong_type.yml": ValidationResults{ + ValidationError{"logo", "wrong type for this field", 18, 1}, + }, + "maintenance_contacts_email_invalid.yml": ValidationResults{ + ValidationError{"maintenance.contacts[0].email", "email must be a valid email address", 49, 9}, + }, + "maintenance_contacts_missing_with_type_community.yml": ValidationResults{ + ValidationError{"maintenance.contacts", "contacts is a required field when \"type\" is \"community\"", 44, 3}, + }, + "maintenance_contacts_missing_with_type_internal.yml": ValidationResults{ + ValidationError{"maintenance.contacts", "contacts is a required field when \"type\" is \"internal\"", 44, 3}, + }, + "maintenance_contacts_name_missing.yml": ValidationResults{ + ValidationError{"maintenance.contacts[0].name", "name is a required field", 47, 7}, + }, + "maintenance_contractors_email_invalid.yml": ValidationResults{ + ValidationError{"maintenance.contractors[0].email", "email must be a valid email address", 50, 8}, + }, + "maintenance_contractors_invalid_type.yml": ValidationResults{ + ValidationError{"maintenance.contractors", "wrong type for this field", 47, 1}, + ValidationError{"maintenance.type", "type is a required field", 44, 3}, + }, + "maintenance_contractors_missing_with_type_contract.yml": ValidationResults{ + ValidationError{"maintenance.contractors", "contractors is a required field when \"type\" is \"contract\"", 44, 3}, + }, + "maintenance_contractors_name_missing.yml": ValidationResults{ + ValidationError{"maintenance.contractors[0].name", "name is a required field", 47, 7}, + }, + "maintenance_contractors_until_invalid.yml": ValidationResults{ + ValidationError{"maintenance.contractors[0].until", "until must be a date with format 'YYYY-MM-DD'", 49, 7}, + }, + "maintenance_contractors_until_missing.yml": ValidationResults{ + ValidationError{"maintenance.contractors[0].until", "until is a required field", 47, 7}, + }, + "maintenance_contractors_website_invalid.yml": ValidationResults{ + ValidationError{"maintenance.contractors[0].website", "website must be an HTTP URL", 52, 7}, + }, + "maintenance_contractors_when_type_is_community.yml": ValidationResults{ + ValidationError{"maintenance.contractors", "contractors must not be present unless \"type\" is \"contract\"", 46, 3}, + }, + "maintenance_contractors_when_type_is_internal.yml": ValidationResults{ + ValidationError{"maintenance.contractors", "contractors must not be present unless \"type\" is \"contract\"", 46, 3}, + }, + "maintenance_contractors_when_type_is_none.yml": ValidationResults{ + ValidationError{"maintenance.contractors", "contractors must not be present unless \"type\" is \"contract\"", 46, 3}, + }, + "maintenance_type_invalid.yml": ValidationResults{ + ValidationError{"maintenance.type", "type must be one of the following: \"internal\", \"contract\", \"community\" or \"none\"", 45, 3}, + }, + "maintenance_type_missing.yml": ValidationResults{ + ValidationError{"maintenance.type", "type is a required field", 47, 3}, + }, + "monochromeLogo_missing_file.yml": ValidationResults{ + ValidationError{"monochromeLogo", "unknown field \"monochromeLogo\"", 18, 1}, + }, + "monochromeLogo_unsupported_extension.yml": ValidationResults{ + ValidationError{"monochromeLogo", "unknown field \"monochromeLogo\"", 18, 1}, + }, + "monochromeLogo_wrong_type.yml": ValidationResults{ + ValidationError{"monochromeLogo", "unknown field \"monochromeLogo\"", 18, 1}, + }, + "mostly_empty.yml": ValidationResults{ + ValidationError{"name", "name is a required field", 1, 1}, + ValidationError{"url", "url is a required field", 1, 1}, + ValidationError{"platforms", "platforms must contain more than 0 items", 1, 1}, + ValidationError{"developmentStatus", "developmentStatus is a required field", 1, 1}, + ValidationError{"softwareType", "softwareType is a required field", 1, 1}, + ValidationError{"description[en-US].shortDescription", "shortDescription is a required field", 3, 10}, + ValidationError{"description[en-US].longDescription", "longDescription is a required field", 3, 10}, + ValidationError{"description[en-US].features", "features must contain more than 0 items", 3, 10}, + ValidationError{"legal.license", "license is a required field", 5, 8}, + ValidationError{"maintenance.type", "type is a required field", 6, 14}, + ValidationError{"localisation.localisationReady", "localisationReady is a required field", 4, 15}, + ValidationError{"localisation.availableLanguages", "availableLanguages is a required field", 4, 15}, + }, + "name_missing.yml": ValidationResults{ + ValidationError{"name", "name is a required field", 1, 1}, + }, + "name_nil.yml": ValidationResults{ + ValidationError{"name", "name is a required field", 4, 1}, + }, + "name_wrong_type.yml": ValidationResults{ + ValidationError{"name", "wrong type for this field", 4, 1}, + ValidationError{"name", "name is a required field", 4, 1}, + }, + "organisation_uri_missing.yml": ValidationResults{ + ValidationError{"organisation.uri", "uri is a required field", 18, 3}, + }, + "organisation_uri_wrong_italian_pa.yml": ValidationResults{ + ValidationError{"organisation.uri", "uri is not a valid URI", 20, 3}, + }, + "organisation_uri_wrong_italian_pa2.yml": ValidationResults{ + ValidationError{"organisation.uri", "uri must be a valid Italian Public Administration Code (iPA) with format 'urn:x-italian-pa:[codiceIPA]' (see https://github.com/publiccodeyml/italian-organizations-ipa-vocabulary)", 19, 3}, + }, + "organisation_wrong_type.yml": ValidationResults{ + ValidationError{"organisation[0]", "wrong type for this field", 18, 1}, + }, + "organisation_wrong_uri.yml": ValidationResults{ + ValidationError{"organisation.uri", "uri is not a valid URI", 19, 3}, + }, + "outputTypes_invalid.yml": ValidationResults{ + ValidationError{"outputTypes", "unknown field \"outputTypes\"", 14, 1}, + }, + "outputTypes_wrong_type.yml": ValidationResults{ + ValidationError{"outputTypes", "unknown field \"outputTypes\"", 14, 1}, + }, + "platforms_missing.yml": ValidationResults{ + ValidationError{"platforms", "platforms must contain more than 0 items", 1, 1}, + }, + "platforms_wrong_type.yml": ValidationResults{ + ValidationError{"platforms", "wrong type for this field", 9, 1}, + ValidationError{"platforms", "platforms must contain more than 0 items", 9, 1}, + }, + "releaseDate_datetime.yml": ValidationResults{ + ValidationError{"releaseDate", "releaseDate must be a date with format 'YYYY-MM-DD'", 8, 1}, + }, + "releaseDate_empty.yml": ValidationResults{ + ValidationError{"releaseDate", "releaseDate must be a date with format 'YYYY-MM-DD'", 8, 1}, + }, + "releaseDate_invalid.yml": ValidationResults{ + ValidationError{"releaseDate", "releaseDate must be a date with format 'YYYY-MM-DD'", 8, 1}, + }, + "releaseDate_wrong_type.yml": ValidationResults{ + ValidationError{"releaseDate", "wrong type for this field", 8, 1}, + }, + "roadmap_invalid.yml": ValidationResults{ + ValidationError{"roadmap", "roadmap must be an HTTP URL", 4, 1}, + ValidationError{"roadmap", "'foobar' not reachable: missing URL scheme", 4, 1}, + }, + "roadmap_wrong_type.yml": ValidationResults{ + ValidationError{"roadmap", "wrong type for this field", 4, 1}, + }, + "softwareType_invalid.yml": ValidationResults{ + ValidationError{"softwareType", "softwareType must be one of the following: \"standalone/mobile\", \"standalone/iot\", \"standalone/desktop\", \"standalone/web\", \"standalone/backend\", \"standalone/other\", \"addon\", \"library\" or \"configurationFiles\"", 17, 1}, + }, + "softwareType_missing.yml": ValidationResults{ + ValidationError{"softwareType", "softwareType is a required field", 1, 1}, + }, + "softwareType_wrong_type.yml": ValidationResults{ + ValidationError{"softwareType", "wrong type for this field", 17, 1}, + ValidationError{"softwareType", "softwareType is a required field", 17, 1}, + }, + "softwareVersion_wrong_type.yml": ValidationResults{ + ValidationError{"softwareVersion", "wrong type for this field", 8, 1}, + }, + "unknown_field.yml": ValidationResults{ + ValidationError{"foobar", "unknown field \"foobar\"", 10, 1}, + }, + "url_invalid.yml": ValidationResults{ + ValidationError{"url", "url must be a valid URL", 6, 1}, + ValidationError{"url", "'foobar' not reachable: missing URL scheme", 6, 1}, + ValidationError{"url", "is not a valid code repository", 6, 1}, + }, + "url_missing.yml": ValidationResults{ + ValidationError{"url", "url is a required field", 1, 1}, + }, + "url_wrong_type.yml": ValidationResults{ + ValidationError{"url", "wrong type for this field", 6, 1}, + ValidationError{"url", "url is a required field", 6, 1}, + }, + "usedBy_wrong_type.yml": ValidationResults{ + ValidationError{"usedBy", "wrong type for this field", 14, 1}, + }, } - expected := ValidationResults{ - ValidationError{ - "publiccodeYmlVersion", - "unsupported version: '1'. Supported versions: 0, 0.2, 0.2.0, 0.2.1, 0.2.2, 0.3, 0.3.0, 0.4, 0.4.0, 0.5.0, 0.5", - 0, - 0, - }, + dir := "testdata/v1/invalid/" + testFiles, _ := filepath.Glob(dir + "*yml") + for _, file := range testFiles { + baseName := path.Base(file) + if expected[baseName] == nil { + t.Errorf("No expected data for file %s", baseName) + } + t.Run(file, func(t *testing.T) { + err := parse(file) + checkParseErrors(t, err, testType{file, expected[baseName]}) + }) + } + for file := range expected { + if !slices.Contains(testFiles, dir+file) { + t.Errorf("No expected file %s", dir+file) + } } +} - for _, dir := range dirs { - testFiles, _ := filepath.Glob(dir + "*yml") +// Test v1 invalid YAML testcases (testdata/v1/invalid/no-network/). +func TestInvalidTestcasesV1_NoNetwork(t *testing.T) { + expected := map[string]error{ + "landingURL_invalid.yml": ValidationResults{ + ValidationError{"IT", "unknown field \"IT\"", 120, 1}, + ValidationError{"landingURL", "landingURL must be an HTTP URL", 8, 1}, + }, + "logo_invalid_png.yml": ValidationResults{ + ValidationError{"logo", "image: unknown format", 18, 1}, + }, + "logo_missing_file.yml": ValidationResults{ + ValidationError{"logo", "no such file: " + cwd + "/testdata/v1/invalid/no-network/no_such_file.png", 18, 1}, + }, + "monochromeLogo_invalid_png.yml": ValidationResults{ + ValidationError{"monochromeLogo", "unknown field \"monochromeLogo\"", 18, 1}, + }, + } - for _, file := range testFiles { - t.Run(file, func(t *testing.T) { - err := parseNoNetwork(file) - checkParseErrors(t, err, testType{file, expected}) - }) + dir := "testdata/v1/invalid/no-network/" + testFiles, _ := filepath.Glob(dir + "*yml") + for _, file := range testFiles { + baseName := path.Base(file) + if expected[baseName] == nil { + t.Errorf("No expected data for file %s", baseName) + } + t.Run(file, func(t *testing.T) { + err := parseNoNetwork(file) + checkParseErrors(t, err, testType{file, expected[baseName]}) + }) + } + for file := range expected { + if !slices.Contains(testFiles, dir+file) { + t.Errorf("No expected file %s", dir+file) } } } From 1dac8100af76feb16bef774d853bde46a0fa230f Mon Sep 17 00:00:00 2001 From: Fabio Bonelli Date: Sun, 14 Jun 2026 07:37:58 +0200 Subject: [PATCH 2/5] test: cover IT country section rejection in publiccode.yml 1 v1 dropped the whole Italian country section, so IT and it are now unknown fields. Moved the conforme, riuso/codiceIPA and lowercase section fixtures into the invalid v1 set. --- .../IT_riuso_codiceIPA.yml} | 1 + .../country_specific_section_downcase.yml} | 2 +- .../it_conforme.yml} | 2 +- v1_test.go | 9 +++++++++ 4 files changed, 12 insertions(+), 2 deletions(-) rename testdata/v1/{invalid_valid_with_warnings_in_v0/valid_with_IT_riuso_codiceIPA.yml => invalid/IT_riuso_codiceIPA.yml} (98%) rename testdata/v1/{invalid_valid_with_warnings_in_v0/valid_with_country_specific_section_downcase.yml => invalid/country_specific_section_downcase.yml} (97%) rename testdata/v1/{invalid_valid_with_warnings_in_v0/valid_with_it_conforme.yml => invalid/it_conforme.yml} (97%) diff --git a/testdata/v1/invalid_valid_with_warnings_in_v0/valid_with_IT_riuso_codiceIPA.yml b/testdata/v1/invalid/IT_riuso_codiceIPA.yml similarity index 98% rename from testdata/v1/invalid_valid_with_warnings_in_v0/valid_with_IT_riuso_codiceIPA.yml rename to testdata/v1/invalid/IT_riuso_codiceIPA.yml index e2710dcc..bf5b4ca0 100644 --- a/testdata/v1/invalid_valid_with_warnings_in_v0/valid_with_IT_riuso_codiceIPA.yml +++ b/testdata/v1/invalid/IT_riuso_codiceIPA.yml @@ -114,6 +114,7 @@ dependsOn: - name: NFC Reader optional: true +# Should NOT validate: the IT section was removed in v1 IT: riuso: codiceIPA: pcm diff --git a/testdata/v1/invalid_valid_with_warnings_in_v0/valid_with_country_specific_section_downcase.yml b/testdata/v1/invalid/country_specific_section_downcase.yml similarity index 97% rename from testdata/v1/invalid_valid_with_warnings_in_v0/valid_with_country_specific_section_downcase.yml rename to testdata/v1/invalid/country_specific_section_downcase.yml index 60e80d63..2c9b7a16 100644 --- a/testdata/v1/invalid_valid_with_warnings_in_v0/valid_with_country_specific_section_downcase.yml +++ b/testdata/v1/invalid/country_specific_section_downcase.yml @@ -103,7 +103,7 @@ dependsOn: - name: NFC Reader2 optional: true -# Should have a warning here, downcase is deprecated: +# Should NOT validate: the it section was removed in v1 it: countryExtensionVersion: "1.0" diff --git a/testdata/v1/invalid_valid_with_warnings_in_v0/valid_with_it_conforme.yml b/testdata/v1/invalid/it_conforme.yml similarity index 97% rename from testdata/v1/invalid_valid_with_warnings_in_v0/valid_with_it_conforme.yml rename to testdata/v1/invalid/it_conforme.yml index 6eb50a41..69217193 100644 --- a/testdata/v1/invalid_valid_with_warnings_in_v0/valid_with_it_conforme.yml +++ b/testdata/v1/invalid/it_conforme.yml @@ -114,8 +114,8 @@ dependsOn: - name: NFC Reader optional: true +# Should NOT validate: the IT section was removed in v1 IT: - # Should have warnings for IT.conforme, because it is deprecated conforme: lineeGuidaDesign: true modelloInteroperabilita: true diff --git a/v1_test.go b/v1_test.go index 1e69d5d9..2f370bc0 100644 --- a/v1_test.go +++ b/v1_test.go @@ -18,6 +18,15 @@ func TestInvalidTestcasesV1(t *testing.T) { "applicationSuite_wrong_type.yml": ValidationResults{ ValidationError{"applicationSuite", "wrong type for this field", 4, 1}, }, + "it_conforme.yml": ValidationResults{ + ValidationError{"IT", "unknown field \"IT\"", 118, 1}, + }, + "IT_riuso_codiceIPA.yml": ValidationResults{ + ValidationError{"IT", "unknown field \"IT\"", 118, 1}, + }, + "country_specific_section_downcase.yml": ValidationResults{ + ValidationError{"it", "unknown field \"it\"", 107, 1}, + }, "categories_invalid.yml": ValidationResults{ ValidationError{"categories[0]", "categories[0] must be a valid category (see https://github.com/publiccodeyml/publiccode.yml/blob/main/docs/standard/categories-list.rst)", 13, 5}, }, From e40e574c799d15c5e90cfe70ed9735b076621d0f Mon Sep 17 00:00:00 2001 From: Fabio Bonelli Date: Sun, 14 Jun 2026 07:02:43 +0200 Subject: [PATCH 3/5] feat: require E.164 phone numbers in v1 phone numvers is now validated as E.164. --- testdata/v1/invalid/IT_riuso_codiceIPA.yml | 6 ++-- testdata/v1/invalid/it_conforme.yml | 6 ++-- testdata/v1/invalid/landingURL_invalid.yml | 6 ++-- testdata/v1/invalid/landingURL_wrong_type.yml | 6 ++-- .../maintenance_contacts_phone_invalid.yml | 36 +++++++++++++++++++ .../invalid/no-network/landingURL_invalid.yml | 6 ++-- v1.go | 2 +- v1_test.go | 3 ++ 8 files changed, 55 insertions(+), 16 deletions(-) create mode 100644 testdata/v1/invalid/maintenance_contacts_phone_invalid.yml diff --git a/testdata/v1/invalid/IT_riuso_codiceIPA.yml b/testdata/v1/invalid/IT_riuso_codiceIPA.yml index bf5b4ca0..6c2fd3da 100644 --- a/testdata/v1/invalid/IT_riuso_codiceIPA.yml +++ b/testdata/v1/invalid/IT_riuso_codiceIPA.yml @@ -79,15 +79,15 @@ maintenance: - name: Francesco Rossi email: "francesco.rossi@comune.reggioemilia.it" affiliation: Comune di Reggio Emilia - phone: +39 231 13215112 + phone: +3923113215112 - name: Dario Bianchi email: "dario.bianchi@fornitore.it" affiliation: Fornitore Privato S.P.A. - phone: +39 16 24231322 + phone: +391624231322 - name: Giancarlo Verdi email: "dario.bianchi@fornitore.it" affiliation: Fornitore Privato S.P.A. - phone: +39 16 24231322 + phone: +391624231322 localisation: localisationReady: true diff --git a/testdata/v1/invalid/it_conforme.yml b/testdata/v1/invalid/it_conforme.yml index 69217193..049bf433 100644 --- a/testdata/v1/invalid/it_conforme.yml +++ b/testdata/v1/invalid/it_conforme.yml @@ -79,15 +79,15 @@ maintenance: - name: Francesco Rossi email: "francesco.rossi@comune.reggioemilia.it" affiliation: Comune di Reggio Emilia - phone: +39 231 13215112 + phone: +3923113215112 - name: Dario Bianchi email: "dario.bianchi@fornitore.it" affiliation: Fornitore Privato S.P.A. - phone: +39 16 24231322 + phone: +391624231322 - name: Giancarlo Verdi email: "dario.bianchi@fornitore.it" affiliation: Fornitore Privato S.P.A. - phone: +39 16 24231322 + phone: +391624231322 localisation: localisationReady: true diff --git a/testdata/v1/invalid/landingURL_invalid.yml b/testdata/v1/invalid/landingURL_invalid.yml index 521b2253..75431aa4 100644 --- a/testdata/v1/invalid/landingURL_invalid.yml +++ b/testdata/v1/invalid/landingURL_invalid.yml @@ -82,15 +82,15 @@ maintenance: - name: Francesco Rossi email: "francesco.rossi@comune.reggioemilia.it" affiliation: Comune di Reggio Emilia - phone: +39 231 13215112 + phone: +3923113215112 - name: Dario Bianchi email: "dario.bianchi@fornitore.it" affiliation: Fornitore Privato S.P.A. - phone: +39 16 24231322 + phone: +391624231322 - name: Giancarlo Verdi email: "dario.bianchi@fornitore.it" affiliation: Fornitore Privato S.P.A. - phone: +39 16 24231322 + phone: +391624231322 localisation: localisationReady: true diff --git a/testdata/v1/invalid/landingURL_wrong_type.yml b/testdata/v1/invalid/landingURL_wrong_type.yml index cb73f212..02a0e205 100644 --- a/testdata/v1/invalid/landingURL_wrong_type.yml +++ b/testdata/v1/invalid/landingURL_wrong_type.yml @@ -78,15 +78,15 @@ maintenance: - name: Francesco Rossi email: "francesco.rossi@comune.reggioemilia.it" affiliation: Comune di Reggio Emilia - phone: +39 231 13215112 + phone: +3923113215112 - name: Dario Bianchi email: "dario.bianchi@fornitore.it" affiliation: Fornitore Privato S.P.A. - phone: +39 16 24231322 + phone: +391624231322 - name: Giancarlo Verdi email: "dario.bianchi@fornitore.it" affiliation: Fornitore Privato S.P.A. - phone: +39 16 24231322 + phone: +391624231322 localisation: localisationReady: true diff --git a/testdata/v1/invalid/maintenance_contacts_phone_invalid.yml b/testdata/v1/invalid/maintenance_contacts_phone_invalid.yml new file mode 100644 index 00000000..cf2f35c0 --- /dev/null +++ b/testdata/v1/invalid/maintenance_contacts_phone_invalid.yml @@ -0,0 +1,36 @@ +publiccodeYmlVersion: "1" + +name: TestSoftware +url: "https://github.com/italia/developers.italia.it.git" + +platforms: + - web + +developmentStatus: stable + +softwareType: "standalone/web" + +description: + en: + shortDescription: "A test software for v1 parsing." + longDescription: > + This is a long description for the test software used to verify + that the v1 parser works correctly. It needs to be at least 150 + characters long to pass validation checks in the publiccode.yml + parser implementation. This is enough text now. + features: + - Test feature + +legal: + license: MIT + +maintenance: + type: internal + contacts: + - name: Test Contact + phone: "06 1234567" + +localisation: + localisationReady: true + availableLanguages: + - en diff --git a/testdata/v1/invalid/no-network/landingURL_invalid.yml b/testdata/v1/invalid/no-network/landingURL_invalid.yml index 521b2253..75431aa4 100644 --- a/testdata/v1/invalid/no-network/landingURL_invalid.yml +++ b/testdata/v1/invalid/no-network/landingURL_invalid.yml @@ -82,15 +82,15 @@ maintenance: - name: Francesco Rossi email: "francesco.rossi@comune.reggioemilia.it" affiliation: Comune di Reggio Emilia - phone: +39 231 13215112 + phone: +3923113215112 - name: Dario Bianchi email: "dario.bianchi@fornitore.it" affiliation: Fornitore Privato S.P.A. - phone: +39 16 24231322 + phone: +391624231322 - name: Giancarlo Verdi email: "dario.bianchi@fornitore.it" affiliation: Fornitore Privato S.P.A. - phone: +39 16 24231322 + phone: +391624231322 localisation: localisationReady: true diff --git a/v1.go b/v1.go index cba0e3fa..8c8fec8d 100644 --- a/v1.go +++ b/v1.go @@ -94,7 +94,7 @@ type ContactV1 struct { Name string `json:"name" validate:"required" yaml:"name"` Email *string `json:"email,omitempty" validate:"omitempty,email" yaml:"email,omitempty"` Affiliation *string `json:"affiliation,omitempty" yaml:"affiliation,omitempty"` - Phone *string `json:"phone,omitempty" validate:"omitempty" yaml:"phone,omitempty"` + Phone *string `json:"phone,omitempty" validate:"omitempty,e164" yaml:"phone,omitempty"` } // DependencyV1 describes system-level dependencies required to install and use this software. diff --git a/v1_test.go b/v1_test.go index 2f370bc0..03951ee2 100644 --- a/v1_test.go +++ b/v1_test.go @@ -250,6 +250,9 @@ func TestInvalidTestcasesV1(t *testing.T) { "maintenance_contacts_email_invalid.yml": ValidationResults{ ValidationError{"maintenance.contacts[0].email", "email must be a valid email address", 49, 9}, }, + "maintenance_contacts_phone_invalid.yml": ValidationResults{ + ValidationError{"maintenance.contacts[0].phone", "phone must be a valid E.164 formatted phone number", 31, 7}, + }, "maintenance_contacts_missing_with_type_community.yml": ValidationResults{ ValidationError{"maintenance.contacts", "contacts is a required field when \"type\" is \"community\"", 44, 3}, }, From 92c23127b44c66cb33471b6711038d21d1cef9de Mon Sep 17 00:00:00 2001 From: Fabio Bonelli Date: Sun, 14 Jun 2026 07:18:20 +0200 Subject: [PATCH 4/5] feat: remove government from the scope list in v1 government is no longer a valid intendedAudience scope in v1. --- fields_v1_test.go | 2 +- .../intendedAudience_scope_government.yml | 38 +++++++++++++++++++ v1.go | 2 +- v1_test.go | 3 ++ validators/v1.go | 16 ++++++++ validators/validator.go | 5 +++ 6 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 testdata/v1/invalid/intendedAudience_scope_government.yml create mode 100644 validators/v1.go diff --git a/fields_v1_test.go b/fields_v1_test.go index 5ccab27c..b9276c55 100644 --- a/fields_v1_test.go +++ b/fields_v1_test.go @@ -270,7 +270,7 @@ func TestValidateFieldsV1WithCountriesDeprecated(t *testing.T) { locReady := true v1 := &PublicCodeV1{ IntendedAudience: &struct { - Scope *[]string `json:"scope,omitempty" validate:"omitempty,dive,is_scope_v0" yaml:"scope,omitempty"` + Scope *[]string `json:"scope,omitempty" validate:"omitempty,dive,is_scope_v1" yaml:"scope,omitempty"` Countries *[]string `json:"countries,omitempty" validate:"omitempty,dive,iso3166_1_alpha2_lower_or_upper" yaml:"countries,omitempty"` UnsupportedCountries *[]string `json:"unsupportedCountries,omitempty" validate:"omitempty,dive,iso3166_1_alpha2_lower_or_upper" yaml:"unsupportedCountries,omitempty"` }{ diff --git a/testdata/v1/invalid/intendedAudience_scope_government.yml b/testdata/v1/invalid/intendedAudience_scope_government.yml new file mode 100644 index 00000000..59654513 --- /dev/null +++ b/testdata/v1/invalid/intendedAudience_scope_government.yml @@ -0,0 +1,38 @@ +publiccodeYmlVersion: "1" + +name: TestSoftware +url: "https://github.com/italia/developers.italia.it.git" + +platforms: + - web + +developmentStatus: stable + +softwareType: "standalone/web" + +intendedAudience: + scope: + # Should NOT validate: government was removed from the scope list in v1 + - government + +description: + en: + shortDescription: "A test software for v1 parsing." + longDescription: > + This is a long description for the test software used to verify + that the v1 parser works correctly. It needs to be at least 150 + characters long to pass validation checks in the publiccode.yml + parser implementation. This is enough text now. + features: + - Test feature + +legal: + license: MIT + +maintenance: + type: none + +localisation: + localisationReady: true + availableLanguages: + - en diff --git a/v1.go b/v1.go index 8c8fec8d..bc11959b 100644 --- a/v1.go +++ b/v1.go @@ -38,7 +38,7 @@ type PublicCodeV1 struct { SoftwareType string `json:"softwareType" validate:"required,oneof=standalone/mobile standalone/iot standalone/desktop standalone/web standalone/backend standalone/other addon library configurationFiles" yaml:"softwareType"` IntendedAudience *struct { - Scope *[]string `json:"scope,omitempty" validate:"omitempty,dive,is_scope_v0" yaml:"scope,omitempty"` + Scope *[]string `json:"scope,omitempty" validate:"omitempty,dive,is_scope_v1" yaml:"scope,omitempty"` Countries *[]string `json:"countries,omitempty" validate:"omitempty,dive,iso3166_1_alpha2_lower_or_upper" yaml:"countries,omitempty"` UnsupportedCountries *[]string `json:"unsupportedCountries,omitempty" validate:"omitempty,dive,iso3166_1_alpha2_lower_or_upper" yaml:"unsupportedCountries,omitempty"` } `json:"intendedAudience,omitempty" yaml:"intendedAudience,omitempty"` diff --git a/v1_test.go b/v1_test.go index 03951ee2..e496edef 100644 --- a/v1_test.go +++ b/v1_test.go @@ -150,6 +150,9 @@ func TestInvalidTestcasesV1(t *testing.T) { "intendedAudience_countries_wrong_type.yml": ValidationResults{ ValidationError{"intendedAudience.countries", "wrong type for this field", 19, 1}, }, + "intendedAudience_scope_government.yml": ValidationResults{ + ValidationError{"intendedAudience.scope[0]", "scope[0] must be a valid scope (see https://github.com/publiccodeyml/publiccode.yml/blob/main/docs/standard/scope-list.rst)", 16, 7}, + }, "intendedAudience_scope_invalid_scope.yml": ValidationResults{ ValidationError{"intendedAudience.scope[0]", "scope[0] must be a valid scope (see https://github.com/publiccodeyml/publiccode.yml/blob/main/docs/standard/scope-list.rst)", 20, 9}, }, diff --git a/validators/v1.go b/validators/v1.go new file mode 100644 index 00000000..3b23e397 --- /dev/null +++ b/validators/v1.go @@ -0,0 +1,16 @@ +package validators + +import "github.com/go-playground/validator/v10" + +// isScopeV1 validates a v1 intendedAudience scope. It is the v0 scope +// list without "government", removed in v1. +func isScopeV1(fl validator.FieldLevel) bool { + s := fl.Field().String() + if s == "government" { + return false + } + + _, ok := supportedScopesV0[s] + + return ok +} diff --git a/validators/validator.go b/validators/validator.go index 01a2577e..19cc2d72 100644 --- a/validators/validator.go +++ b/validators/validator.go @@ -22,6 +22,7 @@ func New() *validator.Validate { _ = validate.RegisterValidation("is_category_v0", isCategoryV0) _ = validate.RegisterValidation("is_scope_v0", isScopeV0) + _ = validate.RegisterValidation("is_scope_v1", isScopeV1) _ = validate.RegisterValidation("is_italian_ipa_code", isItalianIpaCode) @@ -198,6 +199,10 @@ func RegisterLocalErrorMessages(v *validator.Validate, trans ut.Translator) erro tag: "is_scope_v0", translation: "{0} must be a valid scope (see https://github.com/publiccodeyml/publiccode.yml/blob/main/docs/standard/scope-list.rst)", //nolint:lll // long URL in message }, + { + tag: "is_scope_v1", + translation: "{0} must be a valid scope (see https://github.com/publiccodeyml/publiccode.yml/blob/main/docs/standard/scope-list.rst)", //nolint:lll // long URL in message + }, { tag: "is_italian_ipa_code", translation: "{0} must be a valid Italian Public Administration Code (iPA) (see https://github.com/publiccodeyml/italian-organizations-ipa-vocabulary)", //nolint:lll // long URL in message From 4c7b9a759e7aa3b4923e0ee1b97bf4ec05406121 Mon Sep 17 00:00:00 2001 From: Fabio Bonelli Date: Sun, 14 Jun 2026 07:28:35 +0200 Subject: [PATCH 5/5] feat: require ISO 3166-1 country codes in v1 v1 country codes must be strict ISO 3166-1 alpha-2, uppercase only. --- fields.go | 29 ----------------- fields_v1_test.go | 31 ------------------- .../lowercase_countries.yml} | 6 ++-- v1.go | 6 ++-- v1_test.go | 5 +++ validators/validator.go | 5 +++ 6 files changed, 16 insertions(+), 66 deletions(-) rename testdata/v1/{invalid_valid_with_warnings_in_v0/valid_with_lowercase_countries.yml => invalid/lowercase_countries.yml} (97%) diff --git a/fields.go b/fields.go index f16dafc7..2354fb55 100644 --- a/fields.go +++ b/fields.go @@ -318,35 +318,6 @@ func validateFieldsV1(publiccode PublicCode, parser *Parser, network bool, baseU } } - if publiccodev1.IntendedAudience != nil { - // This is not ideal, but we need to revalidate the countries - // here, because otherwise we could get a warning and the advice - // to use uppercase on an invalid country. - if publiccodev1.IntendedAudience.Countries != nil { - for i, c := range *publiccodev1.IntendedAudience.Countries { - if sharedValidate.Var(c, "iso3166_1_alpha2_lower_or_upper") == nil && c == strings.ToLower(c) { - vr = append(vr, ValidationWarning{ - fmt.Sprintf("intendedAudience.countries[%d]", i), - fmt.Sprintf("Lowercase country codes are DEPRECATED. Use uppercase instead ('%s')", strings.ToUpper(c)), - 0, 0, - }) - } - } - } - - if publiccodev1.IntendedAudience.UnsupportedCountries != nil { - for i, c := range *publiccodev1.IntendedAudience.UnsupportedCountries { - if sharedValidate.Var(c, "iso3166_1_alpha2_lower_or_upper") == nil && c == strings.ToLower(c) { - vr = append(vr, ValidationWarning{ - fmt.Sprintf("intendedAudience.unsupportedCountries[%d]", i), - fmt.Sprintf("Lowercase country codes are DEPRECATED. Use uppercase instead ('%s')", strings.ToUpper(c)), - 0, 0, - }) - } - } - } - } - for lang, desc := range publiccodev1.Description { if checksNetwork && desc.Documentation != nil { if reachable, err := parser.isReachable(*(*url.URL)(desc.Documentation)); !reachable { diff --git a/fields_v1_test.go b/fields_v1_test.go index b9276c55..4531728c 100644 --- a/fields_v1_test.go +++ b/fields_v1_test.go @@ -261,37 +261,6 @@ func TestValidateFieldsV1WithAPIDocNetworkCheck(t *testing.T) { } } -func TestValidateFieldsV1WithCountriesDeprecated(t *testing.T) { - p, _ := NewParser(ParserConfig{DisableNetwork: true, DisableExternalChecks: true}) - base := &url.URL{Scheme: "file", Path: "/tmp"} - - countries := []string{"it"} // lowercase - deprecated - unsupported := []string{"de"} // lowercase - deprecated - locReady := true - v1 := &PublicCodeV1{ - IntendedAudience: &struct { - Scope *[]string `json:"scope,omitempty" validate:"omitempty,dive,is_scope_v1" yaml:"scope,omitempty"` - Countries *[]string `json:"countries,omitempty" validate:"omitempty,dive,iso3166_1_alpha2_lower_or_upper" yaml:"countries,omitempty"` - UnsupportedCountries *[]string `json:"unsupportedCountries,omitempty" validate:"omitempty,dive,iso3166_1_alpha2_lower_or_upper" yaml:"unsupportedCountries,omitempty"` - }{ - Countries: &countries, - UnsupportedCountries: &unsupported, - }, - Localisation: struct { - LocalisationReady *bool `json:"localisationReady" validate:"required" yaml:"localisationReady"` - AvailableLanguages []string `json:"availableLanguages" validate:"required,gt=0,dive,bcp47_strict_language_tag" yaml:"availableLanguages"` - }{ - LocalisationReady: &locReady, - AvailableLanguages: []string{"en"}, - }, - } - - err := validateFieldsV1(v1, p, false, base) - if err == nil { - t.Error("expected deprecation warnings for lowercase countries") - } -} - func TestValidateFieldsV0MonochromeLogoAbsPath(t *testing.T) { p, _ := NewParser(ParserConfig{DisableNetwork: true, DisableExternalChecks: false}) base := &url.URL{Scheme: "file", Path: "/tmp"} diff --git a/testdata/v1/invalid_valid_with_warnings_in_v0/valid_with_lowercase_countries.yml b/testdata/v1/invalid/lowercase_countries.yml similarity index 97% rename from testdata/v1/invalid_valid_with_warnings_in_v0/valid_with_lowercase_countries.yml rename to testdata/v1/invalid/lowercase_countries.yml index 05de7e95..363d5eb4 100644 --- a/testdata/v1/invalid_valid_with_warnings_in_v0/valid_with_lowercase_countries.yml +++ b/testdata/v1/invalid/lowercase_countries.yml @@ -80,15 +80,15 @@ maintenance: - name: Francesco Rossi email: "francesco.rossi@comune.reggioemilia.it" affiliation: Comune di Reggio Emilia - phone: +39 231 13215112 + phone: +3923113215112 - name: Dario Bianchi email: "dario.bianchi@fornitore.it" affiliation: Fornitore Privato S.P.A. - phone: +39 16 24231322 + phone: +391624231322 - name: Giancarlo Verdi email: "dario.bianchi@fornitore.it" affiliation: Fornitore Privato S.P.A. - phone: +39 16 24231322 + phone: +391624231322 localisation: localisationReady: true diff --git a/v1.go b/v1.go index bc11959b..98decead 100644 --- a/v1.go +++ b/v1.go @@ -38,9 +38,9 @@ type PublicCodeV1 struct { SoftwareType string `json:"softwareType" validate:"required,oneof=standalone/mobile standalone/iot standalone/desktop standalone/web standalone/backend standalone/other addon library configurationFiles" yaml:"softwareType"` IntendedAudience *struct { - Scope *[]string `json:"scope,omitempty" validate:"omitempty,dive,is_scope_v1" yaml:"scope,omitempty"` - Countries *[]string `json:"countries,omitempty" validate:"omitempty,dive,iso3166_1_alpha2_lower_or_upper" yaml:"countries,omitempty"` - UnsupportedCountries *[]string `json:"unsupportedCountries,omitempty" validate:"omitempty,dive,iso3166_1_alpha2_lower_or_upper" yaml:"unsupportedCountries,omitempty"` + Scope *[]string `json:"scope,omitempty" validate:"omitempty,dive,is_scope_v1" yaml:"scope,omitempty"` + Countries *[]string `json:"countries,omitempty" validate:"omitempty,dive,iso3166_1_alpha2" yaml:"countries,omitempty"` + UnsupportedCountries *[]string `json:"unsupportedCountries,omitempty" validate:"omitempty,dive,iso3166_1_alpha2" yaml:"unsupportedCountries,omitempty"` } `json:"intendedAudience,omitempty" yaml:"intendedAudience,omitempty"` Description map[string]DescV1 `json:"description" validate:"gt=0,bcp47_keys,dive" yaml:"description"` diff --git a/v1_test.go b/v1_test.go index e496edef..293e399e 100644 --- a/v1_test.go +++ b/v1_test.go @@ -147,6 +147,11 @@ func TestInvalidTestcasesV1(t *testing.T) { "intendedAudience_countries_invalid_iso_3166_1_alpha_2.yml": ValidationResults{ ValidationError{"intendedAudience.countries[2]", "countries[2] must be a valid ISO 3166-1 alpha-2 two-letter country code", 22, 7}, }, + "lowercase_countries.yml": ValidationResults{ + ValidationError{"intendedAudience.countries[0]", "countries[0] must be a valid ISO 3166-1 alpha-2 two-letter country code", 31, 7}, + ValidationError{"intendedAudience.countries[1]", "countries[1] must be a valid ISO 3166-1 alpha-2 two-letter country code", 32, 7}, + ValidationError{"intendedAudience.unsupportedCountries[0]", "unsupportedCountries[0] must be a valid ISO 3166-1 alpha-2 two-letter country code", 34, 7}, + }, "intendedAudience_countries_wrong_type.yml": ValidationResults{ ValidationError{"intendedAudience.countries", "wrong type for this field", 19, 1}, }, diff --git a/validators/validator.go b/validators/validator.go index 19cc2d72..75b21515 100644 --- a/validators/validator.go +++ b/validators/validator.go @@ -211,6 +211,11 @@ func RegisterLocalErrorMessages(v *validator.Validate, trans ut.Translator) erro tag: "iso3166_1_alpha2_lower_or_upper", translation: "{0} must be a valid ISO 3166-1 alpha-2 two-letter country code", }, + { + tag: "iso3166_1_alpha2", + override: true, + translation: "{0} must be a valid ISO 3166-1 alpha-2 two-letter country code", + }, { tag: "bcp47_strict_language_tag", override: true,