diff --git a/.github/scripts/validate-proposals.js b/.github/scripts/validate-proposals.js index 35f4615b8..a6a85b598 100644 --- a/.github/scripts/validate-proposals.js +++ b/.github/scripts/validate-proposals.js @@ -2,6 +2,7 @@ const { execSync } = require('child_process'); const fs = require('fs'); +const { validateDirectory, formatErrors } = require('./validate-since'); const witPath = (proposal, version) => { if (version === '0.2') return `proposals/${proposal}/wit`; @@ -92,6 +93,15 @@ for (const { proposal, version } of toValidate) { console.log(`::error::wasm encoding failed for ${proposal} v${version}`); failed = true; } + + // Validate @since annotations + console.log(' Validating @since annotations...'); + const sinceErrors = validateDirectory(witDir); + if (sinceErrors.length > 0) { + console.log(formatErrors(sinceErrors)); + console.log(`::error::@since validation failed for ${proposal} v${version}: ${sinceErrors.length} missing annotation(s)`); + failed = true; + } } finally { console.log('::endgroup::'); } diff --git a/.github/scripts/validate-since.js b/.github/scripts/validate-since.js new file mode 100644 index 000000000..8dd8fe08b --- /dev/null +++ b/.github/scripts/validate-since.js @@ -0,0 +1,182 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); + +/** + * Top-level declarations that require @since or @unstable annotations. + * These are matched at the start of a line (with optional leading whitespace). + */ +const DECLARATION_PATTERNS = [ + { name: 'interface', regex: /^\s*interface\s+([a-z][a-z0-9-]*)\s*\{/i }, + { name: 'world', regex: /^\s*world\s+([a-z][a-z0-9-]*)\s*\{/i }, + { name: 'type', regex: /^\s*type\s+([a-z][a-z0-9-]*)\s*=/i }, + { name: 'record', regex: /^\s*record\s+([a-z][a-z0-9-]*)\s*\{/i }, + { name: 'variant', regex: /^\s*variant\s+([a-z][a-z0-9-]*)\s*\{/i }, + { name: 'enum', regex: /^\s*enum\s+([a-z][a-z0-9-]*)\s*\{/i }, + { name: 'flags', regex: /^\s*flags\s+([a-z][a-z0-9-]*)\s*\{/i }, + { name: 'resource', regex: /^\s*resource\s+([a-z][a-z0-9-]*)\s*[{;]/i }, +]; + +/** + * Annotation patterns that satisfy the @since requirement. + */ +const SINCE_PATTERN = /@since\s*\(\s*version\s*=\s*[0-9a-z.\-]+\s*\)/i; +const UNSTABLE_PATTERN = /@unstable\s*\(\s*feature\s*=\s*[a-z][a-z0-9-]*\s*\)/i; + +/** + * Check if a line has a preceding @since or @unstable annotation. + * Looks backward through lines, skipping doc comments (///). + */ +function hasVersionAnnotation(lines, lineIndex, maxLookback = 20) { + for (let i = 1; i <= Math.min(lineIndex, maxLookback); i++) { + const prevLine = lines[lineIndex - i]; + if (!prevLine) continue; + + const trimmed = prevLine.trim(); + + // Found @since annotation + if (SINCE_PATTERN.test(trimmed)) { + return true; + } + + // Found @unstable annotation (accepted alternative) + if (UNSTABLE_PATTERN.test(trimmed)) { + return true; + } + + // Skip doc comments - continue looking + if (trimmed.startsWith('///')) { + continue; + } + + // Skip other annotations - continue looking + if (trimmed.startsWith('@')) { + continue; + } + + // Skip empty lines - continue looking + if (trimmed === '') { + continue; + } + + // Hit non-annotation, non-comment content - stop looking + break; + } + + return false; +} + +/** + * Validate a single WIT file for @since annotations. + * @param {string} filePath - Path to the WIT file + * @returns {Array} Array of error objects { file, line, declaration, name, message } + */ +function validateFile(filePath) { + const errors = []; + + const content = fs.readFileSync(filePath, 'utf-8'); + const lines = content.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + for (const { name, regex } of DECLARATION_PATTERNS) { + const match = line.match(regex); + if (match) { + if (!hasVersionAnnotation(lines, i)) { + errors.push({ + file: filePath, + line: i + 1, // 1-indexed for display + declaration: name, + name: match[1], + message: `Missing @since annotation for ${name} '${match[1]}'`, + }); + } + break; // Only match one pattern per line + } + } + } + + return errors; +} + +/** + * Validate all WIT files in a directory recursively. + * Excludes deps/ directories. + * @param {string} dirPath - Directory to validate + * @returns {Array} Array of all errors + */ +function validateDirectory(dirPath) { + const errors = []; + + function walkDir(dir) { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + // Skip deps directories + if (entry.name === 'deps') { + continue; + } + walkDir(fullPath); + } else if (entry.name.endsWith('.wit')) { + errors.push(...validateFile(fullPath)); + } + } + } + + walkDir(dirPath); + return errors; +} + +/** + * Format errors for GitHub Actions output (clickable annotations). + * @param {Array} errors - Array of error objects + * @returns {string} Formatted error output + */ +function formatErrors(errors) { + return errors.map(err => { + const relPath = path.relative(process.cwd(), err.file); + return `::error file=${relPath},line=${err.line}::${err.message}`; + }).join('\n'); +} + +// CLI usage: node validate-since.js +if (require.main === module) { + const args = process.argv.slice(2); + + if (args.length === 0) { + console.log('Usage: node validate-since.js '); + console.log('Example: node validate-since.js proposals/io/wit'); + process.exit(1); + } + + const targetDir = args[0]; + + if (!fs.existsSync(targetDir)) { + console.error(`Directory not found: ${targetDir}`); + process.exit(1); + } + + console.log(`Validating @since annotations in ${targetDir}...\n`); + + const errors = validateDirectory(targetDir); + + if (errors.length > 0) { + console.log(formatErrors(errors)); + console.log(`\n${errors.length} missing @since annotation(s) found.`); + process.exit(1); + } else { + console.log('All declarations have @since annotations.'); + process.exit(0); + } +} + +module.exports = { + validateFile, + validateDirectory, + formatErrors, + DECLARATION_PATTERNS, +}; diff --git a/proposals/cli/wit-0.3.0-draft/deps.lock b/proposals/cli/wit-0.3.0-draft/deps.lock index c9cf98f03..9d73740f5 100644 --- a/proposals/cli/wit-0.3.0-draft/deps.lock +++ b/proposals/cli/wit-0.3.0-draft/deps.lock @@ -5,8 +5,8 @@ sha512 = "702dd507f4d26b7b2ddfcfe8186532683824a21af1c9eadbe47359690e83be66c047c5 [filesystem] path = "../../filesystem/wit-0.3.0-draft" -sha256 = "ab88210ca207526acc50e0b942d3edd05a2c4108bc261a8e0b3aa26ddd03e71a" -sha512 = "6a23790610c34d8d1c5e70c9464be18f61e85a27caabdcf80c173e4b53ece69a078957f2ba8b7e0835f23149c37447fad9945ec0a62ff144749348939f321e27" +sha256 = "73bb959d03febb7c68f2c4a272ffa32c4e91f0ee0244ff1e78caa762f6576c3f" +sha512 = "187d10c64fb2c3172214be6fdb81255551abf5df4a9677da080c4e1e3cb598a6d370af371b01ceb068ae7dd2454acf8e2a5c14a51855e177ef50303361588b5a" [random] path = "../../random/wit-0.3.0-draft" diff --git a/proposals/cli/wit/deps.lock b/proposals/cli/wit/deps.lock index 1c7d052f8..423df6b14 100644 --- a/proposals/cli/wit/deps.lock +++ b/proposals/cli/wit/deps.lock @@ -5,8 +5,8 @@ sha512 = "fc16682461807392565b7f7a3bc01d233e794a8dd82bd5de9263e608c96cc3aa754d0f [filesystem] path = "../../filesystem/wit" -sha256 = "840d7b3c0a3cac44f90fbc875f9772788220cbfd63881570b504b614087d2f76" -sha512 = "c9266a095e4a0f6cc4e071d7da54ae88d9c70128681fd66fdf5ee626f35636db8afbabdb73ce2b28959e107c750abbc014aec29423e7363614a180d3c9075776" +sha256 = "2b48a0bf3deb4cb8c8eff917dc0bc05f493c6cfaea064e9f4972aafe2859ab4f" +sha512 = "97a4dc8dfd0782dde809e2a087a989d973013af1bb7e76533db47fe9d00f97c30ca4b9e269bd938c14f4a57d07e14af196525ba57300c79bf1632997b915e41d" [io] path = "../../io/wit" diff --git a/proposals/filesystem/wit-0.3.0-draft/types.wit b/proposals/filesystem/wit-0.3.0-draft/types.wit index c482e7ed6..70271e2e0 100644 --- a/proposals/filesystem/wit-0.3.0-draft/types.wit +++ b/proposals/filesystem/wit-0.3.0-draft/types.wit @@ -167,6 +167,7 @@ interface types { } /// A directory entry. + @since(version = 0.3.0-rc-2026-01-06) record directory-entry { /// The type of the file referred to by this directory entry. %type: descriptor-type, @@ -179,6 +180,7 @@ interface types { /// Not all of these error codes are returned by the functions provided by this /// API; some are used in higher-level library layers, and others are provided /// merely for alignment with POSIX. + @since(version = 0.3.0-rc-2026-01-06) enum error-code { /// Permission denied, similar to `EACCES` in POSIX. access, diff --git a/proposals/filesystem/wit/types.wit b/proposals/filesystem/wit/types.wit index 35ecbfccc..ccfc39d0f 100644 --- a/proposals/filesystem/wit/types.wit +++ b/proposals/filesystem/wit/types.wit @@ -169,6 +169,7 @@ interface types { } /// A directory entry. + @since(version = 0.2.0) record directory-entry { /// The type of the file referred to by this directory entry. %type: descriptor-type, @@ -181,6 +182,7 @@ interface types { /// Not all of these error codes are returned by the functions provided by this /// API; some are used in higher-level library layers, and others are provided /// merely for alignment with POSIX. + @since(version = 0.2.0) enum error-code { /// Permission denied, similar to `EACCES` in POSIX. access, diff --git a/proposals/http/wit-0.3.0-draft/deps.lock b/proposals/http/wit-0.3.0-draft/deps.lock index 27d9309cb..7569a0b15 100644 --- a/proposals/http/wit-0.3.0-draft/deps.lock +++ b/proposals/http/wit-0.3.0-draft/deps.lock @@ -10,8 +10,8 @@ sha256 = "92b3fbb2700613b35f3fa8f2cc9d9b9a93b9d81feebedd4d071ab9c28cdc66e8" sha512 = "702dd507f4d26b7b2ddfcfe8186532683824a21af1c9eadbe47359690e83be66c047c54eb29e71efc20de27d9f4b643610e4102880a93b45fb76c3e808252877" [filesystem] -sha256 = "ab88210ca207526acc50e0b942d3edd05a2c4108bc261a8e0b3aa26ddd03e71a" -sha512 = "6a23790610c34d8d1c5e70c9464be18f61e85a27caabdcf80c173e4b53ece69a078957f2ba8b7e0835f23149c37447fad9945ec0a62ff144749348939f321e27" +sha256 = "73bb959d03febb7c68f2c4a272ffa32c4e91f0ee0244ff1e78caa762f6576c3f" +sha512 = "187d10c64fb2c3172214be6fdb81255551abf5df4a9677da080c4e1e3cb598a6d370af371b01ceb068ae7dd2454acf8e2a5c14a51855e177ef50303361588b5a" [random] sha256 = "f8bc74d443aacc210c1ff76617bfbd41f118185a8cdbafcd1b69347eaa817b18" diff --git a/proposals/http/wit-0.3.0-draft/types.wit b/proposals/http/wit-0.3.0-draft/types.wit index ba25c7142..bf2d9991a 100644 --- a/proposals/http/wit-0.3.0-draft/types.wit +++ b/proposals/http/wit-0.3.0-draft/types.wit @@ -1,9 +1,13 @@ +package wasi:http@0.3.0-rc-2026-01-06; + /// This interface defines all of the types and methods for implementing HTTP /// Requests and Responses, as well as their headers, trailers, and bodies. +@since(version = 0.3.0-rc-2026-01-06) interface types { use wasi:clocks/types@0.3.0-rc-2026-01-06.{duration}; /// This type corresponds to HTTP standard Methods. + @since(version = 0.3.0-rc-2026-01-06) variant method { get, head, @@ -18,6 +22,7 @@ interface types { } /// This type corresponds to HTTP standard Related Schemes. + @since(version = 0.3.0-rc-2026-01-06) variant scheme { HTTP, HTTPS, @@ -26,6 +31,7 @@ interface types { /// These cases are inspired by the IANA HTTP Proxy Error Types: /// + @since(version = 0.3.0-rc-2026-01-06) variant error-code { DNS-timeout, DNS-error(DNS-error-payload), @@ -74,18 +80,21 @@ interface types { } /// Defines the case payload type for `DNS-error` above: + @since(version = 0.3.0-rc-2026-01-06) record DNS-error-payload { rcode: option, info-code: option } /// Defines the case payload type for `TLS-alert-received` above: + @since(version = 0.3.0-rc-2026-01-06) record TLS-alert-received-payload { alert-id: option, alert-message: option } /// Defines the case payload type for `HTTP-response-{header,trailer}-size` above: + @since(version = 0.3.0-rc-2026-01-06) record field-size-payload { field-name: option, field-size: option @@ -93,6 +102,7 @@ interface types { /// This type enumerates the different kinds of errors that may occur when /// setting or appending to a `fields` resource. + @since(version = 0.3.0-rc-2026-01-06) variant header-error { /// This error indicates that a `field-name` or `field-value` was /// syntactically invalid when used with an operation that sets headers in a @@ -110,6 +120,7 @@ interface types { /// This type enumerates the different kinds of errors that may occur when /// setting fields of a `request-options` resource. + @since(version = 0.3.0-rc-2026-01-06) variant request-options-error { /// Indicates the specified field is not supported by this implementation. not-supported, @@ -123,11 +134,13 @@ interface types { /// /// Field names should always be treated as case insensitive by the `fields` /// resource for the purposes of equality checking. + @since(version = 0.3.0-rc-2026-01-06) type field-name = string; /// Field values should always be ASCII strings. However, in /// reality, HTTP implementations often have to interpret malformed values, /// so they are provided as a list of bytes. + @since(version = 0.3.0-rc-2026-01-06) type field-value = list; /// This following block defines the `fields` resource which corresponds to @@ -145,6 +158,7 @@ interface types { /// original casing used to construct or mutate the `fields` resource. The `fields` /// resource should use that original casing when serializing the fields for /// transport or when returning them from a method. + @since(version = 0.3.0-rc-2026-01-06) resource fields { /// Construct an empty HTTP Fields. @@ -225,12 +239,15 @@ interface types { } /// Headers is an alias for Fields. + @since(version = 0.3.0-rc-2026-01-06) type headers = fields; /// Trailers is an alias for Fields. + @since(version = 0.3.0-rc-2026-01-06) type trailers = fields; /// Represents an HTTP Request. + @since(version = 0.3.0-rc-2026-01-06) resource request { /// Construct a new `request` with a default `method` of `GET`, and @@ -330,6 +347,7 @@ interface types { /// /// These timeouts are separate from any the user may use to bound an /// asynchronous call. + @since(version = 0.3.0-rc-2026-01-06) resource request-options { /// Construct a default `request-options` value. constructor(); @@ -365,9 +383,11 @@ interface types { } /// This type corresponds to the HTTP standard Status Code. + @since(version = 0.3.0-rc-2026-01-06) type status-code = u16; /// Represents an HTTP Response. + @since(version = 0.3.0-rc-2026-01-06) resource response { /// Construct a new `response`, with a default `status-code` of `200`. diff --git a/proposals/http/wit-0.3.0-draft/worlds.wit b/proposals/http/wit-0.3.0-draft/worlds.wit index dc9a4da79..3e6932b9e 100644 --- a/proposals/http/wit-0.3.0-draft/worlds.wit +++ b/proposals/http/wit-0.3.0-draft/worlds.wit @@ -3,6 +3,7 @@ package wasi:http@0.3.0-rc-2026-01-06; /// The `wasi:http/service` world captures a broad category of HTTP services /// including web applications, API servers, and proxies. It may be `include`d /// in more specific worlds such as `wasi:http/middleware`. +@since(version = 0.3.0-rc-2026-01-06) world service { /// HTTP services have access to time and randomness. include wasi:clocks/imports@0.3.0-rc-2026-01-06; @@ -39,6 +40,7 @@ world service { /// Components may implement this world to allow them to participate in handler /// "chains" where a `request` flows through handlers on its way to some terminal /// `service` and corresponding `response` flows in the opposite direction. +@since(version = 0.3.0-rc-2026-01-06) world middleware { include service; import handler; @@ -51,6 +53,7 @@ world middleware { /// /// In `wasi:http/middleware` this interface is both exported and imported as /// the "downstream" and "upstream" directions of the middleware chain. +@since(version = 0.3.0-rc-2026-01-06) interface handler { use types.{request, response, error-code}; @@ -71,6 +74,7 @@ interface handler { /// (including WIT itself) is unable to represent a component importing two /// instances of the same interface. A `client.send` import may be linked /// directly to a `handler.handle` export to bypass the network. +@since(version = 0.3.0-rc-2026-01-06) interface client { use types.{request, response, error-code}; @@ -79,4 +83,4 @@ interface client { send: async func( request: request, ) -> result; -} \ No newline at end of file +} diff --git a/proposals/http/wit/deps.lock b/proposals/http/wit/deps.lock index c282428cc..cb0d47ce9 100644 --- a/proposals/http/wit/deps.lock +++ b/proposals/http/wit/deps.lock @@ -10,8 +10,8 @@ sha256 = "b9fe6015a69eec7ce906a2fe9e6422e09d84e5905f8b2c48c9f9db4932ae6336" sha512 = "fc16682461807392565b7f7a3bc01d233e794a8dd82bd5de9263e608c96cc3aa754d0f5d1e9c1573faece8c7ffd3a87b7290bbcdf6d515478c5bcf1b0cb9e5c7" [filesystem] -sha256 = "840d7b3c0a3cac44f90fbc875f9772788220cbfd63881570b504b614087d2f76" -sha512 = "c9266a095e4a0f6cc4e071d7da54ae88d9c70128681fd66fdf5ee626f35636db8afbabdb73ce2b28959e107c750abbc014aec29423e7363614a180d3c9075776" +sha256 = "2b48a0bf3deb4cb8c8eff917dc0bc05f493c6cfaea064e9f4972aafe2859ab4f" +sha512 = "97a4dc8dfd0782dde809e2a087a989d973013af1bb7e76533db47fe9d00f97c30ca4b9e269bd938c14f4a57d07e14af196525ba57300c79bf1632997b915e41d" [io] path = "../../io/wit"