Skip to content

Commit 16ac86b

Browse files
authored
Merge pull request #821 from Saboleee/feature/link-contract-interfaces-docs
feat(contract): link interface docs to code with drift check
2 parents e48512e + 3b9ca8a commit 16ac86b

12 files changed

Lines changed: 429 additions & 6 deletions

contract/docs/interfaces/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,12 @@ Examples assume local deployment (`contract/deployed-local.json`). Replace:
2525

2626
Run: `soroban contract invoke --network local --source registry --wasm path/to/target.wasm --dry-run` to validate.
2727

28+
## Drift Check
29+
30+
To ensure contract interface docs stay linked to code, run:
31+
32+
- `npm run check:interfaces` (validates all `docs/interfaces/*.md` method tables against referenced `contracts/**/src/lib.rs` files)
33+
- `npm run test:interfaces` (parser/unit tests for the drift check)
34+
35+
The release checklist (`npm run release-check`) now includes the interface drift check.
36+

contract/docs/interfaces/content-access.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@ Pay-per-content unlocking.
1111
| `has_access` | `buyer: Address, creator: Address, content_id: u64` | `bool` | none | `soroban contract invoke ... has_access -- BUYER CREATOR 123` | None |
1212
| `get_content_price` | `creator: Address, content_id: u64` | `Option<i128>` | none | `soroban contract invoke ... get_content_price -- CREATOR 123` | None |
1313
| `set_content_price` | `creator: Address, content_id: u64, price: i128` | `()` | creator | `soroban contract invoke ... set_content_price -- CREATOR 123 100` | None |
14+
| `verify_access` | `claimer: Address, creator: Address, content_id: u64` | `()` | none | `soroban contract invoke ... verify_access -- BUYER CREATOR 123` | None |
15+
| `set_max_price` | `max_price: i128` | `()` | admin | `soroban contract invoke ... set_max_price -- 1000000` | None |
16+
| `get_max_price` | `()` | `Option<i128>` | none | `soroban contract invoke ... get_max_price` | None |
1417
| `set_admin` | `new_admin: Address` | `()` | current admin | `soroban contract invoke ... set_admin -- NEW_ADMIN` | None |
18+
| `admin` | `()` | `Address` | none | `soroban contract invoke ... admin` | None |
1519

1620
## Overview
1721
Buyer pays creator-set price to unlock specific content. Access buyer/creator/content-specific. Idempotent unlocks.

contract/docs/interfaces/creator-registry.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ Rate-limited creator registration.
77
| Method | Args | Returns | Auth | Example Invoke | Expected Events |
88
|--------|------|---------|------|---------------|-----------------|
99
| `initialize` | `admin: Address` | `()` | admin | `soroban contract invoke ... initialize -- ADMIN` | None |
10+
| `set_rate_limit` | `ledgers: u32` | `()` | admin | `soroban contract invoke ... set_rate_limit -- 10` | None |
11+
| `set_spam_fee` | `token: Address, amount: i128` | `()` | admin | `soroban contract invoke ... set_spam_fee -- TOKEN 1000` | None |
1012
| `register_creator` | `caller: Address, creator_address: Address, creator_id: u64` | `()` | admin/creator (+ rate limit) | `soroban contract invoke ... register_creator -- CALLER CREATOR 456` | None |
13+
| `unregister_creator` | `creator_address: Address` | `()` | admin | `soroban contract invoke ... unregister_creator -- CREATOR` | None |
14+
| `admin` | `()` | `Address` | none | `soroban contract invoke ... admin` | None |
1115
| `get_creator_id` | `address: Address` | `Option<u64>` | none | `soroban contract invoke ... get_creator_id -- ADDR` | None |
1216

1317
## Overview

contract/docs/interfaces/earnings.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Admin-tracked earnings totals.
1010
| `admin` | `()` | `Address` | none | `soroban contract invoke ... admin` | None |
1111
| `record` | `creator: Address, amount: i128` | `()` | admin | `soroban contract invoke ... record -- CREATOR 1000` | None |
1212
| `get_earnings` | `creator: Address` | `i128` | none | `soroban contract invoke ... get_earnings -- CREATOR` | None |
13+
| `withdraw` | `creator: Address, amount: i128` | `()` | creator | `soroban contract invoke ... withdraw -- CREATOR 500` | `("withdrawn", creator) -> amount` |
1314

1415
## Overview
1516
Admin records per-creator earnings totals. Simple accumulator.

contract/docs/interfaces/myfans-main.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# MyFans Main Contract Interface (contract/src/lib.rs)
1+
# MyFans Main Contract Interface (contracts/myfans-contract/src/lib.rs)
22

33
Core subscription and creator registry.
44

@@ -12,6 +12,8 @@ Core subscription and creator registry.
1212
| `get_creator` | `address: Address` | `Option<CreatorInfo>` | none | `soroban contract invoke ... get_creator -- ADDR` | None |
1313
| `create_plan` | `creator: Address, asset: Address, amount: i128, interval_days: u32` | `u32` (plan_id) | creator | `soroban contract invoke ... create_plan -- CREATOR TOKEN_ID 1000 30` | `("plan_created", plan_id) -> creator` |
1414
| `subscribe` | `fan: Address, plan_id: u32` | `()` | fan | `soroban contract invoke ... subscribe -- FAN_ADDR 1` (fund fan balance first) | `("subscribed", plan_id) -> fan` |
15+
| `get_plan` | `plan_id: u32` | `Option<Plan>` | none | `soroban contract invoke ... get_plan -- 1` | None |
16+
| `get_plan_count` | `()` | `u32` | none | `soroban contract invoke ... get_plan_count` | None |
1517
| `is_subscriber` / `is_subscribed` | `fan: Address, creator: Address` | `bool` | none | `soroban contract invoke ... is_subscriber -- FAN CREATOR` | None |
1618
| `get_subscription_expiry` | `fan: Address, creator: Address` | `Option<u64>` | none | `soroban contract invoke ... get_subscription_expiry -- FAN CREATOR` | None |
1719
| `cancel` | `fan: Address, creator: Address` | `()` | fan | `soroban contract invoke ... cancel -- FAN CREATOR` | `("cancelled",) -> fan` |

contract/docs/interfaces/myfans-token.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@ Standard token implementation.
1212
| `name` / `symbol` / `decimals` / `total_supply` | `()` | `String/u32/i128` | none | `soroban contract invoke ... name` | None |
1313
| `approve` | `from: Address, spender: Address, amount: i128, expiration_ledger: u32` | `()` | from | `soroban contract invoke ... approve -- FROM SPENDER 100 1000000` | `("approve", from, spender) -> amount` |
1414
| `transfer_from` | `spender: Address, from: Address, to: Address, amount: i128` | `()` | spender | `soroban contract invoke ... transfer_from -- SPENDER FROM TO 100` | `("transfer", from, to) -> amount` |
15+
| `clear_allowance` | `from: Address, spender: Address` | `()` | from | `soroban contract invoke ... clear_allowance -- FROM SPENDER` | None |
1516
| `allowance` | `from: Address, spender: Address` | `i128` | none | `soroban contract invoke ... allowance -- FROM SPENDER` | None |
1617
| `mint` | `to: Address, amount: i128` | `()` | admin? | `soroban contract invoke ... mint -- TO 1000` | `("mint", to) -> amount` |
18+
| `burn` | `from: Address, amount: i128` | `()` | from | `soroban contract invoke ... burn -- FROM 100` | `("burn", from) -> amount` |
1719
| `balance` / `transfer` | `id: Address` / `from: Address, to: Address, amount: i128` | `i128` / `()` | none/from | `soroban contract invoke ... transfer -- FROM TO 100` | `("transfer", from, to) -> amount` |
1820

1921
## Overview

contract/docs/interfaces/subscription.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,18 @@ Advanced subscription with ledger expiry.
77
| Method | Args | Returns | Auth | Example Invoke | Expected Events |
88
|--------|------|---------|------|---------------|-----------------|
99
| `init` | `admin: Address, fee_bps: u32, fee_recipient: Address, token: Address, price: i128` | `()` | admin | `soroban contract invoke ... init -- ADMIN 100 TREASURY TOKEN 1000` | None |
10+
| `admin` | `()` | `Address` | none | `soroban contract invoke ... admin` | None |
1011
| `create_plan` | `creator: Address, asset: Address, amount: i128, interval_days: u32` | `u32` | creator | `soroban contract invoke ... create_plan -- CREATOR TOKEN 1000 30` | `("plan_created", plan_id) -> creator` |
1112
| `subscribe` | `fan: Address, plan_id: u32, token: Address` | `()` | fan | `soroban contract invoke ... subscribe -- FAN 1 TOKEN` | `("subscribed", plan_id) -> fan` |
1213
| `is_subscriber` | `fan: Address, creator: Address` | `bool` | none | `soroban contract invoke ... is_subscriber -- FAN CREATOR` | None |
1314
| `extend_subscription` | `fan: Address, creator: Address, extra_ledgers: u32, token: Address` | `()` | fan | `soroban contract invoke ... extend_subscription -- FAN CREATOR 100 TOKEN` | `("extended", plan_id) -> fan` |
1415
| `cancel` | `fan: Address, creator: Address, reason: u32` | `()` | fan | `soroban contract invoke ... cancel -- FAN CREATOR 0` | `("cancelled", fan, creator) -> (true, reason)` |
1516
| `create_subscription` | `fan: Address, creator: Address, duration_ledgers: u32` | `()` | fan | `soroban contract invoke ... create_subscription -- FAN CREATOR 17280` | None (internal) |
1617
| `pause` / `unpause` | `()` | `()` | admin | `soroban contract invoke ... pause --` | `("paused" / "unpaused",) -> admin` |
18+
| `set_fee_recipient` | `new_fee_recipient: Address` | `()` | admin | `soroban contract invoke ... set_fee_recipient -- RECIPIENT` | `("fee_recipient_updated", old, new) -> ()` |
19+
| `set_fee_bps` | `new_fee_bps: u32` | `()` | admin | `soroban contract invoke ... set_fee_bps -- 250` | `("fee_updated",) -> (old, new)` |
1720
| `is_paused` | `()` | `bool` | none | `soroban contract invoke ... is_paused` | None |
21+
| `get_expiry_unix` | `fan: Address, creator: Address` | `(u64, u64)` | none | `soroban contract invoke ... get_expiry_unix -- FAN CREATOR` | None |
1822

1923
## Overview
2024
Subscription plans with extend/cancel; overlaps main contract. Uses ledger seq for expiry.

contract/docs/interfaces/treasury-src.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Treasury (src/treasury.rs)
1+
# Treasury (contracts/myfans-contract/src/treasury.rs)
22

33
Basic treasury.
44

contract/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
"scripts": {
55
"build": "cargo build --release --target wasm32-unknown-unknown",
66
"test": "cargo test",
7+
"test:interfaces": "node --test ./scripts/check-interface-docs-drift.test.mjs",
8+
"check:interfaces": "node ./scripts/check-interface-docs-drift.mjs",
79
"release-check": "./scripts/release-check.sh",
810
"release-check:skip-abi": "./scripts/release-check.sh --skip-abi",
911
"release-check:ci": "./scripts/release-check.sh --skip-abi --skip-dry-run"
Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
#!/usr/bin/env node
2+
3+
import fs from 'node:fs';
4+
import path from 'node:path';
5+
import { fileURLToPath } from 'node:url';
6+
7+
const __filename = fileURLToPath(import.meta.url);
8+
const __dirname = path.dirname(__filename);
9+
const DEFAULT_ROOT = path.resolve(__dirname, '..');
10+
const INTERFACES_DIR = path.join(DEFAULT_ROOT, 'docs', 'interfaces');
11+
12+
const KNOWN_NON_CONTRACT_DOCS = new Set(['README.md', 'runbook.md']);
13+
14+
function readText(filePath) {
15+
return fs.readFileSync(filePath, 'utf8');
16+
}
17+
18+
function parseSourcePathFromHeading(markdown) {
19+
const heading = markdown.match(/^# .*\(([^)]+)\)/m);
20+
if (!heading) return null;
21+
return heading[1].trim();
22+
}
23+
24+
function parseDocumentedMethods(markdown) {
25+
const lines = markdown.split(/\r?\n/);
26+
const methods = new Set();
27+
let inMethodsTable = false;
28+
29+
for (const line of lines) {
30+
if (/^\s*\|\s*Method\s*\|/i.test(line)) {
31+
inMethodsTable = true;
32+
continue;
33+
}
34+
35+
if (!inMethodsTable) {
36+
continue;
37+
}
38+
39+
if (!line.trim().startsWith('|')) {
40+
break;
41+
}
42+
43+
if (/^\s*\|\s*-+\s*\|/.test(line)) {
44+
continue;
45+
}
46+
47+
const columns = line.split('|');
48+
if (columns.length < 3) {
49+
continue;
50+
}
51+
52+
const rawMethodCell = columns[1].trim();
53+
if (!rawMethodCell) {
54+
continue;
55+
}
56+
57+
const unquoted = rawMethodCell.replace(/`/g, '');
58+
// Support grouped rows such as "pause / unpause"
59+
const grouped = unquoted.split('/').map((part) => part.trim());
60+
for (const methodName of grouped) {
61+
if (!methodName || /\s/.test(methodName)) {
62+
continue;
63+
}
64+
methods.add(methodName);
65+
}
66+
}
67+
68+
return methods;
69+
}
70+
71+
function parseContractMethods(rustSource) {
72+
const blocks = extractContractImplBlocks(rustSource);
73+
const methods = new Set();
74+
if (blocks.length === 0) {
75+
return methods;
76+
}
77+
78+
// Production contract entrypoints are expected in the first #[contractimpl] block.
79+
// This intentionally ignores test-only helper contracts declared later in #[cfg(test)] modules.
80+
const targetBlock = blocks[0];
81+
const regex = /\bpub\s+fn\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\(/g;
82+
let match;
83+
while ((match = regex.exec(targetBlock)) !== null) {
84+
methods.add(match[1]);
85+
}
86+
return methods;
87+
}
88+
89+
function extractContractImplBlocks(rustSource) {
90+
const blocks = [];
91+
const marker = '#[contractimpl]';
92+
let cursor = 0;
93+
94+
while (cursor < rustSource.length) {
95+
const markerIdx = rustSource.indexOf(marker, cursor);
96+
if (markerIdx === -1) {
97+
break;
98+
}
99+
100+
const implIdx = rustSource.indexOf('impl', markerIdx + marker.length);
101+
if (implIdx === -1) {
102+
break;
103+
}
104+
105+
const braceStart = rustSource.indexOf('{', implIdx);
106+
if (braceStart === -1) {
107+
break;
108+
}
109+
110+
let depth = 0;
111+
let end = braceStart;
112+
for (; end < rustSource.length; end++) {
113+
const ch = rustSource[end];
114+
if (ch === '{') {
115+
depth += 1;
116+
} else if (ch === '}') {
117+
depth -= 1;
118+
if (depth === 0) {
119+
end += 1;
120+
break;
121+
}
122+
}
123+
}
124+
125+
if (depth === 0) {
126+
blocks.push(rustSource.slice(braceStart, end));
127+
cursor = end;
128+
} else {
129+
break;
130+
}
131+
}
132+
133+
return blocks;
134+
}
135+
136+
function toSortedArray(set) {
137+
return Array.from(set).sort();
138+
}
139+
140+
function setDiff(left, right) {
141+
const diff = new Set();
142+
for (const item of left) {
143+
if (!right.has(item)) {
144+
diff.add(item);
145+
}
146+
}
147+
return diff;
148+
}
149+
150+
function collectInterfaceMarkdownFiles(interfacesDir) {
151+
return fs
152+
.readdirSync(interfacesDir)
153+
.filter((entry) => entry.endsWith('.md'))
154+
.filter((entry) => !KNOWN_NON_CONTRACT_DOCS.has(entry))
155+
.sort()
156+
.map((entry) => path.join(interfacesDir, entry));
157+
}
158+
159+
function validateDocFile(rootDir, docPath) {
160+
const markdown = readText(docPath);
161+
const headingSourcePath = parseSourcePathFromHeading(markdown);
162+
if (!headingSourcePath) {
163+
return {
164+
ok: false,
165+
docPath,
166+
error:
167+
'Missing source path in H1 heading, expected e.g. "# Name (contracts/.../src/lib.rs)".',
168+
};
169+
}
170+
171+
const sourcePath = path.join(rootDir, headingSourcePath);
172+
if (!fs.existsSync(sourcePath)) {
173+
return {
174+
ok: false,
175+
docPath,
176+
sourcePath,
177+
error: `Referenced source file does not exist: ${headingSourcePath}`,
178+
};
179+
}
180+
181+
const documentedMethods = parseDocumentedMethods(markdown);
182+
if (documentedMethods.size === 0) {
183+
return {
184+
ok: false,
185+
docPath,
186+
sourcePath,
187+
error: 'No methods found in Methods table.',
188+
};
189+
}
190+
191+
const contractMethods = parseContractMethods(readText(sourcePath));
192+
const undocumented = setDiff(contractMethods, documentedMethods);
193+
const staleDocs = setDiff(documentedMethods, contractMethods);
194+
195+
return {
196+
ok: undocumented.size === 0 && staleDocs.size === 0,
197+
docPath,
198+
sourcePath,
199+
undocumented: toSortedArray(undocumented),
200+
staleDocs: toSortedArray(staleDocs),
201+
};
202+
}
203+
204+
export function checkInterfaceDocsDrift(rootDir = DEFAULT_ROOT) {
205+
const interfacesDir = path.join(rootDir, 'docs', 'interfaces');
206+
const docFiles = collectInterfaceMarkdownFiles(interfacesDir);
207+
208+
const results = docFiles.map((docPath) => validateDocFile(rootDir, docPath));
209+
const failed = results.filter((result) => !result.ok);
210+
211+
return {
212+
ok: failed.length === 0,
213+
checked: results.length,
214+
results,
215+
failed,
216+
};
217+
}
218+
219+
function relativeFromRoot(filePath, rootDir) {
220+
return path.relative(rootDir, filePath) || filePath;
221+
}
222+
223+
function printFailures(summary, rootDir) {
224+
for (const result of summary.failed) {
225+
const label = relativeFromRoot(result.docPath, rootDir);
226+
console.error(`- ${label}`);
227+
if (result.error) {
228+
console.error(` error: ${result.error}`);
229+
continue;
230+
}
231+
if (result.undocumented?.length) {
232+
console.error(` undocumented methods: ${result.undocumented.join(', ')}`);
233+
}
234+
if (result.staleDocs?.length) {
235+
console.error(` stale docs methods: ${result.staleDocs.join(', ')}`);
236+
}
237+
}
238+
}
239+
240+
function runCli() {
241+
if (!fs.existsSync(INTERFACES_DIR)) {
242+
console.error(`interfaces dir not found: ${INTERFACES_DIR}`);
243+
process.exit(1);
244+
}
245+
246+
const summary = checkInterfaceDocsDrift(DEFAULT_ROOT);
247+
if (summary.ok) {
248+
console.log(
249+
`Interface docs drift check passed (${summary.checked} files).`,
250+
);
251+
return;
252+
}
253+
254+
console.error(
255+
`Interface docs drift check failed (${summary.failed.length}/${summary.checked} files).`,
256+
);
257+
printFailures(summary, DEFAULT_ROOT);
258+
process.exit(1);
259+
}
260+
261+
const invokedDirectly =
262+
process.argv[1] && path.resolve(process.argv[1]) === __filename;
263+
264+
if (invokedDirectly) {
265+
runCli();
266+
}
267+
268+
export {
269+
parseSourcePathFromHeading,
270+
parseDocumentedMethods,
271+
parseContractMethods,
272+
extractContractImplBlocks,
273+
collectInterfaceMarkdownFiles,
274+
validateDocFile,
275+
};

0 commit comments

Comments
 (0)