Skip to content

Commit 7e64351

Browse files
authored
lint since (#867)
* ci: validate-since linter * fix(filesystem): add missing `@since` * fix(http): add missing `@since`
1 parent d9e97fe commit 7e64351

10 files changed

Lines changed: 229 additions & 9 deletions

File tree

.github/scripts/validate-proposals.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
const { execSync } = require('child_process');
44
const fs = require('fs');
5+
const { validateDirectory, formatErrors } = require('./validate-since');
56

67
const witPath = (proposal, version) => {
78
if (version === '0.2') return `proposals/${proposal}/wit`;
@@ -92,6 +93,15 @@ for (const { proposal, version } of toValidate) {
9293
console.log(`::error::wasm encoding failed for ${proposal} v${version}`);
9394
failed = true;
9495
}
96+
97+
// Validate @since annotations
98+
console.log(' Validating @since annotations...');
99+
const sinceErrors = validateDirectory(witDir);
100+
if (sinceErrors.length > 0) {
101+
console.log(formatErrors(sinceErrors));
102+
console.log(`::error::@since validation failed for ${proposal} v${version}: ${sinceErrors.length} missing annotation(s)`);
103+
failed = true;
104+
}
95105
} finally {
96106
console.log('::endgroup::');
97107
}

.github/scripts/validate-since.js

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
#!/usr/bin/env node
2+
3+
const fs = require('fs');
4+
const path = require('path');
5+
6+
/**
7+
* Top-level declarations that require @since or @unstable annotations.
8+
* These are matched at the start of a line (with optional leading whitespace).
9+
*/
10+
const DECLARATION_PATTERNS = [
11+
{ name: 'interface', regex: /^\s*interface\s+([a-z][a-z0-9-]*)\s*\{/i },
12+
{ name: 'world', regex: /^\s*world\s+([a-z][a-z0-9-]*)\s*\{/i },
13+
{ name: 'type', regex: /^\s*type\s+([a-z][a-z0-9-]*)\s*=/i },
14+
{ name: 'record', regex: /^\s*record\s+([a-z][a-z0-9-]*)\s*\{/i },
15+
{ name: 'variant', regex: /^\s*variant\s+([a-z][a-z0-9-]*)\s*\{/i },
16+
{ name: 'enum', regex: /^\s*enum\s+([a-z][a-z0-9-]*)\s*\{/i },
17+
{ name: 'flags', regex: /^\s*flags\s+([a-z][a-z0-9-]*)\s*\{/i },
18+
{ name: 'resource', regex: /^\s*resource\s+([a-z][a-z0-9-]*)\s*[{;]/i },
19+
];
20+
21+
/**
22+
* Annotation patterns that satisfy the @since requirement.
23+
*/
24+
const SINCE_PATTERN = /@since\s*\(\s*version\s*=\s*[0-9a-z.\-]+\s*\)/i;
25+
const UNSTABLE_PATTERN = /@unstable\s*\(\s*feature\s*=\s*[a-z][a-z0-9-]*\s*\)/i;
26+
27+
/**
28+
* Check if a line has a preceding @since or @unstable annotation.
29+
* Looks backward through lines, skipping doc comments (///).
30+
*/
31+
function hasVersionAnnotation(lines, lineIndex, maxLookback = 20) {
32+
for (let i = 1; i <= Math.min(lineIndex, maxLookback); i++) {
33+
const prevLine = lines[lineIndex - i];
34+
if (!prevLine) continue;
35+
36+
const trimmed = prevLine.trim();
37+
38+
// Found @since annotation
39+
if (SINCE_PATTERN.test(trimmed)) {
40+
return true;
41+
}
42+
43+
// Found @unstable annotation (accepted alternative)
44+
if (UNSTABLE_PATTERN.test(trimmed)) {
45+
return true;
46+
}
47+
48+
// Skip doc comments - continue looking
49+
if (trimmed.startsWith('///')) {
50+
continue;
51+
}
52+
53+
// Skip other annotations - continue looking
54+
if (trimmed.startsWith('@')) {
55+
continue;
56+
}
57+
58+
// Skip empty lines - continue looking
59+
if (trimmed === '') {
60+
continue;
61+
}
62+
63+
// Hit non-annotation, non-comment content - stop looking
64+
break;
65+
}
66+
67+
return false;
68+
}
69+
70+
/**
71+
* Validate a single WIT file for @since annotations.
72+
* @param {string} filePath - Path to the WIT file
73+
* @returns {Array} Array of error objects { file, line, declaration, name, message }
74+
*/
75+
function validateFile(filePath) {
76+
const errors = [];
77+
78+
const content = fs.readFileSync(filePath, 'utf-8');
79+
const lines = content.split('\n');
80+
81+
for (let i = 0; i < lines.length; i++) {
82+
const line = lines[i];
83+
84+
for (const { name, regex } of DECLARATION_PATTERNS) {
85+
const match = line.match(regex);
86+
if (match) {
87+
if (!hasVersionAnnotation(lines, i)) {
88+
errors.push({
89+
file: filePath,
90+
line: i + 1, // 1-indexed for display
91+
declaration: name,
92+
name: match[1],
93+
message: `Missing @since annotation for ${name} '${match[1]}'`,
94+
});
95+
}
96+
break; // Only match one pattern per line
97+
}
98+
}
99+
}
100+
101+
return errors;
102+
}
103+
104+
/**
105+
* Validate all WIT files in a directory recursively.
106+
* Excludes deps/ directories.
107+
* @param {string} dirPath - Directory to validate
108+
* @returns {Array} Array of all errors
109+
*/
110+
function validateDirectory(dirPath) {
111+
const errors = [];
112+
113+
function walkDir(dir) {
114+
const entries = fs.readdirSync(dir, { withFileTypes: true });
115+
for (const entry of entries) {
116+
const fullPath = path.join(dir, entry.name);
117+
118+
if (entry.isDirectory()) {
119+
// Skip deps directories
120+
if (entry.name === 'deps') {
121+
continue;
122+
}
123+
walkDir(fullPath);
124+
} else if (entry.name.endsWith('.wit')) {
125+
errors.push(...validateFile(fullPath));
126+
}
127+
}
128+
}
129+
130+
walkDir(dirPath);
131+
return errors;
132+
}
133+
134+
/**
135+
* Format errors for GitHub Actions output (clickable annotations).
136+
* @param {Array} errors - Array of error objects
137+
* @returns {string} Formatted error output
138+
*/
139+
function formatErrors(errors) {
140+
return errors.map(err => {
141+
const relPath = path.relative(process.cwd(), err.file);
142+
return `::error file=${relPath},line=${err.line}::${err.message}`;
143+
}).join('\n');
144+
}
145+
146+
// CLI usage: node validate-since.js <directory>
147+
if (require.main === module) {
148+
const args = process.argv.slice(2);
149+
150+
if (args.length === 0) {
151+
console.log('Usage: node validate-since.js <directory>');
152+
console.log('Example: node validate-since.js proposals/io/wit');
153+
process.exit(1);
154+
}
155+
156+
const targetDir = args[0];
157+
158+
if (!fs.existsSync(targetDir)) {
159+
console.error(`Directory not found: ${targetDir}`);
160+
process.exit(1);
161+
}
162+
163+
console.log(`Validating @since annotations in ${targetDir}...\n`);
164+
165+
const errors = validateDirectory(targetDir);
166+
167+
if (errors.length > 0) {
168+
console.log(formatErrors(errors));
169+
console.log(`\n${errors.length} missing @since annotation(s) found.`);
170+
process.exit(1);
171+
} else {
172+
console.log('All declarations have @since annotations.');
173+
process.exit(0);
174+
}
175+
}
176+
177+
module.exports = {
178+
validateFile,
179+
validateDirectory,
180+
formatErrors,
181+
DECLARATION_PATTERNS,
182+
};

proposals/cli/wit-0.3.0-draft/deps.lock

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ sha512 = "702dd507f4d26b7b2ddfcfe8186532683824a21af1c9eadbe47359690e83be66c047c5
55

66
[filesystem]
77
path = "../../filesystem/wit-0.3.0-draft"
8-
sha256 = "ab88210ca207526acc50e0b942d3edd05a2c4108bc261a8e0b3aa26ddd03e71a"
9-
sha512 = "6a23790610c34d8d1c5e70c9464be18f61e85a27caabdcf80c173e4b53ece69a078957f2ba8b7e0835f23149c37447fad9945ec0a62ff144749348939f321e27"
8+
sha256 = "73bb959d03febb7c68f2c4a272ffa32c4e91f0ee0244ff1e78caa762f6576c3f"
9+
sha512 = "187d10c64fb2c3172214be6fdb81255551abf5df4a9677da080c4e1e3cb598a6d370af371b01ceb068ae7dd2454acf8e2a5c14a51855e177ef50303361588b5a"
1010

1111
[random]
1212
path = "../../random/wit-0.3.0-draft"

proposals/cli/wit/deps.lock

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ sha512 = "fc16682461807392565b7f7a3bc01d233e794a8dd82bd5de9263e608c96cc3aa754d0f
55

66
[filesystem]
77
path = "../../filesystem/wit"
8-
sha256 = "840d7b3c0a3cac44f90fbc875f9772788220cbfd63881570b504b614087d2f76"
9-
sha512 = "c9266a095e4a0f6cc4e071d7da54ae88d9c70128681fd66fdf5ee626f35636db8afbabdb73ce2b28959e107c750abbc014aec29423e7363614a180d3c9075776"
8+
sha256 = "2b48a0bf3deb4cb8c8eff917dc0bc05f493c6cfaea064e9f4972aafe2859ab4f"
9+
sha512 = "97a4dc8dfd0782dde809e2a087a989d973013af1bb7e76533db47fe9d00f97c30ca4b9e269bd938c14f4a57d07e14af196525ba57300c79bf1632997b915e41d"
1010

1111
[io]
1212
path = "../../io/wit"

proposals/filesystem/wit-0.3.0-draft/types.wit

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ interface types {
167167
}
168168

169169
/// A directory entry.
170+
@since(version = 0.3.0-rc-2026-01-06)
170171
record directory-entry {
171172
/// The type of the file referred to by this directory entry.
172173
%type: descriptor-type,
@@ -179,6 +180,7 @@ interface types {
179180
/// Not all of these error codes are returned by the functions provided by this
180181
/// API; some are used in higher-level library layers, and others are provided
181182
/// merely for alignment with POSIX.
183+
@since(version = 0.3.0-rc-2026-01-06)
182184
enum error-code {
183185
/// Permission denied, similar to `EACCES` in POSIX.
184186
access,

proposals/filesystem/wit/types.wit

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ interface types {
169169
}
170170

171171
/// A directory entry.
172+
@since(version = 0.2.0)
172173
record directory-entry {
173174
/// The type of the file referred to by this directory entry.
174175
%type: descriptor-type,
@@ -181,6 +182,7 @@ interface types {
181182
/// Not all of these error codes are returned by the functions provided by this
182183
/// API; some are used in higher-level library layers, and others are provided
183184
/// merely for alignment with POSIX.
185+
@since(version = 0.2.0)
184186
enum error-code {
185187
/// Permission denied, similar to `EACCES` in POSIX.
186188
access,

proposals/http/wit-0.3.0-draft/deps.lock

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ sha256 = "92b3fbb2700613b35f3fa8f2cc9d9b9a93b9d81feebedd4d071ab9c28cdc66e8"
1010
sha512 = "702dd507f4d26b7b2ddfcfe8186532683824a21af1c9eadbe47359690e83be66c047c54eb29e71efc20de27d9f4b643610e4102880a93b45fb76c3e808252877"
1111

1212
[filesystem]
13-
sha256 = "ab88210ca207526acc50e0b942d3edd05a2c4108bc261a8e0b3aa26ddd03e71a"
14-
sha512 = "6a23790610c34d8d1c5e70c9464be18f61e85a27caabdcf80c173e4b53ece69a078957f2ba8b7e0835f23149c37447fad9945ec0a62ff144749348939f321e27"
13+
sha256 = "73bb959d03febb7c68f2c4a272ffa32c4e91f0ee0244ff1e78caa762f6576c3f"
14+
sha512 = "187d10c64fb2c3172214be6fdb81255551abf5df4a9677da080c4e1e3cb598a6d370af371b01ceb068ae7dd2454acf8e2a5c14a51855e177ef50303361588b5a"
1515

1616
[random]
1717
sha256 = "f8bc74d443aacc210c1ff76617bfbd41f118185a8cdbafcd1b69347eaa817b18"

proposals/http/wit-0.3.0-draft/types.wit

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
1+
package wasi:http@0.3.0-rc-2026-01-06;
2+
13
/// This interface defines all of the types and methods for implementing HTTP
24
/// Requests and Responses, as well as their headers, trailers, and bodies.
5+
@since(version = 0.3.0-rc-2026-01-06)
36
interface types {
47
use wasi:clocks/types@0.3.0-rc-2026-01-06.{duration};
58

69
/// This type corresponds to HTTP standard Methods.
10+
@since(version = 0.3.0-rc-2026-01-06)
711
variant method {
812
get,
913
head,
@@ -18,6 +22,7 @@ interface types {
1822
}
1923

2024
/// This type corresponds to HTTP standard Related Schemes.
25+
@since(version = 0.3.0-rc-2026-01-06)
2126
variant scheme {
2227
HTTP,
2328
HTTPS,
@@ -26,6 +31,7 @@ interface types {
2631

2732
/// These cases are inspired by the IANA HTTP Proxy Error Types:
2833
/// <https://www.iana.org/assignments/http-proxy-status/http-proxy-status.xhtml#table-http-proxy-error-types>
34+
@since(version = 0.3.0-rc-2026-01-06)
2935
variant error-code {
3036
DNS-timeout,
3137
DNS-error(DNS-error-payload),
@@ -74,25 +80,29 @@ interface types {
7480
}
7581

7682
/// Defines the case payload type for `DNS-error` above:
83+
@since(version = 0.3.0-rc-2026-01-06)
7784
record DNS-error-payload {
7885
rcode: option<string>,
7986
info-code: option<u16>
8087
}
8188

8289
/// Defines the case payload type for `TLS-alert-received` above:
90+
@since(version = 0.3.0-rc-2026-01-06)
8391
record TLS-alert-received-payload {
8492
alert-id: option<u8>,
8593
alert-message: option<string>
8694
}
8795

8896
/// Defines the case payload type for `HTTP-response-{header,trailer}-size` above:
97+
@since(version = 0.3.0-rc-2026-01-06)
8998
record field-size-payload {
9099
field-name: option<string>,
91100
field-size: option<u32>
92101
}
93102

94103
/// This type enumerates the different kinds of errors that may occur when
95104
/// setting or appending to a `fields` resource.
105+
@since(version = 0.3.0-rc-2026-01-06)
96106
variant header-error {
97107
/// This error indicates that a `field-name` or `field-value` was
98108
/// syntactically invalid when used with an operation that sets headers in a
@@ -110,6 +120,7 @@ interface types {
110120

111121
/// This type enumerates the different kinds of errors that may occur when
112122
/// setting fields of a `request-options` resource.
123+
@since(version = 0.3.0-rc-2026-01-06)
113124
variant request-options-error {
114125
/// Indicates the specified field is not supported by this implementation.
115126
not-supported,
@@ -123,11 +134,13 @@ interface types {
123134
///
124135
/// Field names should always be treated as case insensitive by the `fields`
125136
/// resource for the purposes of equality checking.
137+
@since(version = 0.3.0-rc-2026-01-06)
126138
type field-name = string;
127139

128140
/// Field values should always be ASCII strings. However, in
129141
/// reality, HTTP implementations often have to interpret malformed values,
130142
/// so they are provided as a list of bytes.
143+
@since(version = 0.3.0-rc-2026-01-06)
131144
type field-value = list<u8>;
132145

133146
/// This following block defines the `fields` resource which corresponds to
@@ -145,6 +158,7 @@ interface types {
145158
/// original casing used to construct or mutate the `fields` resource. The `fields`
146159
/// resource should use that original casing when serializing the fields for
147160
/// transport or when returning them from a method.
161+
@since(version = 0.3.0-rc-2026-01-06)
148162
resource fields {
149163

150164
/// Construct an empty HTTP Fields.
@@ -225,12 +239,15 @@ interface types {
225239
}
226240

227241
/// Headers is an alias for Fields.
242+
@since(version = 0.3.0-rc-2026-01-06)
228243
type headers = fields;
229244

230245
/// Trailers is an alias for Fields.
246+
@since(version = 0.3.0-rc-2026-01-06)
231247
type trailers = fields;
232248

233249
/// Represents an HTTP Request.
250+
@since(version = 0.3.0-rc-2026-01-06)
234251
resource request {
235252

236253
/// Construct a new `request` with a default `method` of `GET`, and
@@ -330,6 +347,7 @@ interface types {
330347
///
331348
/// These timeouts are separate from any the user may use to bound an
332349
/// asynchronous call.
350+
@since(version = 0.3.0-rc-2026-01-06)
333351
resource request-options {
334352
/// Construct a default `request-options` value.
335353
constructor();
@@ -365,9 +383,11 @@ interface types {
365383
}
366384

367385
/// This type corresponds to the HTTP standard Status Code.
386+
@since(version = 0.3.0-rc-2026-01-06)
368387
type status-code = u16;
369388

370389
/// Represents an HTTP Response.
390+
@since(version = 0.3.0-rc-2026-01-06)
371391
resource response {
372392

373393
/// Construct a new `response`, with a default `status-code` of `200`.

0 commit comments

Comments
 (0)