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
10 changes: 10 additions & 0 deletions .github/scripts/validate-proposals.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand Down Expand Up @@ -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::');
}
Expand Down
182 changes: 182 additions & 0 deletions .github/scripts/validate-since.js
Original file line number Diff line number Diff line change
@@ -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 <directory>
if (require.main === module) {
const args = process.argv.slice(2);

if (args.length === 0) {
console.log('Usage: node validate-since.js <directory>');
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,
};
4 changes: 2 additions & 2 deletions proposals/cli/wit-0.3.0-draft/deps.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions proposals/cli/wit/deps.lock
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ sha512 = "fc16682461807392565b7f7a3bc01d233e794a8dd82bd5de9263e608c96cc3aa754d0f

[filesystem]
path = "../../filesystem/wit"
sha256 = "840d7b3c0a3cac44f90fbc875f9772788220cbfd63881570b504b614087d2f76"
sha512 = "c9266a095e4a0f6cc4e071d7da54ae88d9c70128681fd66fdf5ee626f35636db8afbabdb73ce2b28959e107c750abbc014aec29423e7363614a180d3c9075776"
sha256 = "2b48a0bf3deb4cb8c8eff917dc0bc05f493c6cfaea064e9f4972aafe2859ab4f"
sha512 = "97a4dc8dfd0782dde809e2a087a989d973013af1bb7e76533db47fe9d00f97c30ca4b9e269bd938c14f4a57d07e14af196525ba57300c79bf1632997b915e41d"

[io]
path = "../../io/wit"
Expand Down
2 changes: 2 additions & 0 deletions proposals/filesystem/wit-0.3.0-draft/types.wit
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions proposals/filesystem/wit/types.wit
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions proposals/http/wit-0.3.0-draft/deps.lock
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ sha256 = "92b3fbb2700613b35f3fa8f2cc9d9b9a93b9d81feebedd4d071ab9c28cdc66e8"
sha512 = "702dd507f4d26b7b2ddfcfe8186532683824a21af1c9eadbe47359690e83be66c047c54eb29e71efc20de27d9f4b643610e4102880a93b45fb76c3e808252877"

[filesystem]
sha256 = "ab88210ca207526acc50e0b942d3edd05a2c4108bc261a8e0b3aa26ddd03e71a"
sha512 = "6a23790610c34d8d1c5e70c9464be18f61e85a27caabdcf80c173e4b53ece69a078957f2ba8b7e0835f23149c37447fad9945ec0a62ff144749348939f321e27"
sha256 = "73bb959d03febb7c68f2c4a272ffa32c4e91f0ee0244ff1e78caa762f6576c3f"
sha512 = "187d10c64fb2c3172214be6fdb81255551abf5df4a9677da080c4e1e3cb598a6d370af371b01ceb068ae7dd2454acf8e2a5c14a51855e177ef50303361588b5a"

[random]
sha256 = "f8bc74d443aacc210c1ff76617bfbd41f118185a8cdbafcd1b69347eaa817b18"
Expand Down
20 changes: 20 additions & 0 deletions proposals/http/wit-0.3.0-draft/types.wit
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
package wasi:[email protected];

/// 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/[email protected].{duration};

/// This type corresponds to HTTP standard Methods.
@since(version = 0.3.0-rc-2026-01-06)
variant method {
get,
head,
Expand All @@ -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,
Expand All @@ -26,6 +31,7 @@ interface types {

/// These cases are inspired by the IANA HTTP Proxy Error Types:
/// <https://www.iana.org/assignments/http-proxy-status/http-proxy-status.xhtml#table-http-proxy-error-types>
@since(version = 0.3.0-rc-2026-01-06)
variant error-code {
DNS-timeout,
DNS-error(DNS-error-payload),
Expand Down Expand Up @@ -74,25 +80,29 @@ 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<string>,
info-code: option<u16>
}

/// 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<u8>,
alert-message: option<string>
}

/// 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<string>,
field-size: option<u32>
}

/// 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
Expand All @@ -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,
Expand All @@ -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<u8>;

/// This following block defines the `fields` resource which corresponds to
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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`.
Expand Down
Loading