Skip to content

Commit ea71901

Browse files
authored
Merge pull request #867 from threefoldtech/development
Dev to Master Sync: documented-command linting
2 parents b64e559 + 534e212 commit ea71901

9 files changed

Lines changed: 241 additions & 13 deletions

File tree

.github/workflows/pr_check.yml

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@ name: PR Check
22

33
# Validates a pull request before it is merged.
44
#
5-
# The site build is the gate: with onBrokenLinks and onBrokenAnchors set to
6-
# 'throw' in docusaurus.config.js, any broken internal link or anchor fails
7-
# this job. External links are NOT checked here -- third-party rot must never
8-
# block a merge. Those are covered by the weekly link check instead.
5+
# Two gates:
6+
# - the site build, which fails on a broken internal link or anchor
7+
# (onBrokenLinks / onBrokenAnchors are 'throw' in docusaurus.config.js)
8+
# - scripts/check_docs_commands.py, which lints every shell command printed
9+
# in the manual and flags command blocks that have drifted between the
10+
# farmers/ and labs/ copies of a page
11+
#
12+
# External links are NOT checked here -- third-party rot must never block a
13+
# merge. Those are covered by the weekly link check instead.
914

1015
on:
1116
pull_request:
@@ -31,3 +36,11 @@ jobs:
3136
- name: Build site
3237
# Fails on broken internal links and anchors.
3338
run: npm run build
39+
40+
- name: Install shellcheck
41+
run: shellcheck --version >/dev/null 2>&1 || sudo apt-get install -y shellcheck
42+
43+
- name: Check documented shell commands
44+
# Lints every shell block in the manual and flags farmers/ vs labs/
45+
# command drift. See the script's docstring for what it skips and why.
46+
run: python3 scripts/check_docs_commands.py

labs/docs/documentation/developers/flist/api_token.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ curl -H "Authorization: bearer <API_Token>" https://hub.grid.tf/api/flist/me
2424

2525
You should see the following line with your own 3BotID
2626

27-
```bash
27+
```json
2828
{"status": "success", "payload": {"username": "<3BotID>.3bot"}}
2929
```
3030

labs/docs/documentation/developers/proxy_readme/db_testing.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ you can either Generate a db with relevant schema to test things locally quickly
3030
--postgres-db tfgrid-graphql \
3131
--postgres-password postgres \
3232
--postgres-user postgres \
33-
--reset \
33+
--reset
3434
```
3535

3636
### Method 2: Fill the DB from a Production db dump file, for example if you have `dump.sql` file, you can run:

labs/docs/documentation/farmers/farmerbot_intro/farmerbot_information.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ farmerbot start --node <node ID> -m "<mnemonic>" -n <network> -d
6767

6868
Where:
6969

70-
```bash
70+
```text
7171
Flags:
7272
--node uint32 the node ID you want to use
7373
@@ -87,7 +87,7 @@ farmerbot start all --farm <farm ID> -m "<mnemonic>" -n <network> -d
8787

8888
Where:
8989

90-
```bash
90+
```text
9191
Flags:
9292
--farm uint32 the farm ID you want to start your nodes ins
9393

labs/docs/documentation/farmers/farming_optimization/cloud_provider_farming.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ EFI variables are not supported on this system.
108108
```
109109

110110
- A server in UEFI mode would have the following output:
111-
```sh
111+
```console
112112
root@rescue ~ # efibootmgr
113113
BootCurrent: 0001
114114
Timeout: 1 seconds

labs/docs/documentation/system_administrators/computer_it_basics/docker_basics.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -368,10 +368,10 @@ To clean up container images created X hours ago, you can use the following temp
368368
docker image prune -a --force --filter "until=Xh"
369369
```
370370
371-
To clean up container images created before a given date, you can use the following template (replace ` with the complete date):
371+
To clean up container images created before a given date, you can use the following template (replace `<date>` with the complete date):
372372
373373
```
374-
docker image prune -a --force --filter "until=`"
374+
docker image prune -a --force --filter "until=<date>"
375375
```
376376
377377
Note: An example of a complete date would be `2023-01-04T00:00:00`

labs/docs/documentation/system_administrators/mycelium_toc/message.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ Listen for a message on node2. Note that messages received while nothing is list
3131
a queue for later consumption. Wait for up to 1 minute.
3232

3333
```bash
34-
curl -v http://localhost:8989/api/v1/messages\?timeout\=60\
34+
curl -v http://localhost:8989/api/v1/messages\?timeout\=60
3535
```
3636

3737
The system will (immediately) receive our previously sent message:

labs/docs/documentation/system_administrators/tfgrid3_getstarted/ssh_guide/advanced_methods/ssh_wsl.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ Then choose the key name and passphrase or simply press return twice to accept t
3232
When the process has finished, the private key and the public key can be found in the `~/.ssh` directory accessible from the Ubuntu terminal.
3333
You can also access the key from Windows file manager in the following folder:
3434

35-
```sh
35+
```text
3636
\\wsl$\\Ubuntu\home\<username>\.ssh\
3737
```
3838

scripts/check_docs_commands.py

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
#!/usr/bin/env python3
2+
"""Validate the shell commands printed in the manual.
3+
4+
Two checks, both aimed at the same failure: a farmer copies a command out of
5+
the manual and it does not work.
6+
7+
1. SHELL -- every shell block is linted with shellcheck (severity=error),
8+
falling back to `bash -n` when shellcheck is unavailable.
9+
10+
shellcheck rather than `bash -n` because `bash -n` only checks syntax.
11+
The disk-wipe loop that silently wiped nothing --
12+
13+
for i in /dev/sd*; do if [ "$i"!= "/dev/sdX"* ]; then ... ; fi; done
14+
15+
-- is syntactically VALID; it fails at runtime with "unary operator
16+
expected". `bash -n` passes it. shellcheck catches it (SC1108, "you need
17+
a space before and after the =").
18+
19+
This covers ```bash / ```sh blocks AND untagged ``` blocks whose first
20+
line looks like a command. Untagged blocks matter: most of the manual's
21+
commands are untagged, and that wipe loop lived in one.
22+
23+
The manual's own conventions are respected, not fought:
24+
- `<placeholder>` is normalised before linting, because angle brackets
25+
are shell redirection and every page uses them as placeholders.
26+
- Pasted terminal sessions (first line is a prompt) are skipped, since
27+
they are output rather than commands. Tag genuine output as ```text
28+
or ```console rather than ```bash.
29+
- Blocks containing a heredoc are skipped: the body is data, and
30+
shellcheck mis-parses it when the block is linted out of context.
31+
32+
2. TWIN DRIFT -- farmers/ and labs/ deliberately carry two versions of the
33+
same five build pages, written for different audiences. The prose is
34+
meant to differ; the commands are not. This flags a command block that
35+
exists in both but has drifted, which is what happens when a fix lands
36+
in one tree and is forgotten in the other.
37+
38+
Exits non-zero if either check fails. Run from the repo root.
39+
"""
40+
41+
import difflib
42+
import os
43+
import re
44+
import subprocess
45+
import sys
46+
import tempfile
47+
48+
SKIP_DIRS = ('build', '.docusaurus', 'node_modules', '.git')
49+
SHELL_LANGS = {'bash', 'sh', 'shell', 'zsh'}
50+
51+
# A pasted terminal session: "$ cmd", "# cmd", or "user@host ... $ cmd".
52+
PROMPT = re.compile(r'^\s*(\$\s|#\s|\S+@\S+.*?[#$]\s)')
53+
PLACEHOLDER = re.compile(r'<[A-Za-z0-9_\-. /]+>')
54+
FENCE = re.compile(r'^(\s*)```(\S*)\s*$')
55+
56+
# An untagged block is treated as shell when its first line opens with a
57+
# command. Deliberately conservative: it is better to skip an odd block than
58+
# to fail the build on a config file someone forgot to tag.
59+
SHELLY = re.compile(
60+
r'^\s*(sudo|apt|apt-get|wget|curl|git|cd|mkdir|echo|export|for |if |while '
61+
r'|docker|systemctl|chmod|chown|ln |cp |mv |rm |tar|ssh|scp|npm|yarn'
62+
r'|cargo|pip|make|set |source |\./)\b')
63+
64+
# Pages that exist in both trees. Prose may differ; commands must not.
65+
TWINS = [
66+
('farmers/docs/3node_building/{}.md',
67+
'labs/docs/documentation/farmers/3node_building/{}.md')
68+
]
69+
TWIN_PAGES = ['2_bootstrap_image', '3_set_hardware', '4_wipe_all_disks',
70+
'5_set_bios_uefi', '6_boot_3node']
71+
72+
73+
def markdown_files():
74+
for root, dirs, files in os.walk('.'):
75+
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
76+
for f in files:
77+
if f.endswith(('.md', '.mdx')):
78+
yield os.path.join(root, f).replace('./', '', 1)
79+
80+
81+
def code_blocks(path):
82+
"""Yield (lang, start_line, body) for each fenced block."""
83+
lines = open(path, encoding='utf-8', errors='replace').read().split('\n')
84+
i = 0
85+
while i < len(lines):
86+
m = FENCE.match(lines[i])
87+
if not m:
88+
i += 1
89+
continue
90+
lang = m.group(2).lower()
91+
start = i + 1
92+
body = []
93+
i += 1
94+
while i < len(lines) and not lines[i].strip() == '```' and not FENCE.match(lines[i]):
95+
body.append(lines[i])
96+
i += 1
97+
yield lang, start, '\n'.join(body)
98+
i += 1
99+
100+
101+
def have_shellcheck():
102+
try:
103+
subprocess.run(['shellcheck', '--version'],
104+
capture_output=True, check=True)
105+
return True
106+
except (OSError, subprocess.CalledProcessError):
107+
return False
108+
109+
110+
def check_syntax():
111+
failures = []
112+
checked = 0
113+
sc = have_shellcheck()
114+
for path in sorted(markdown_files()):
115+
for lang, line, body in code_blocks(path):
116+
if not body.strip():
117+
continue
118+
first = next((l for l in body.split('\n') if l.strip()), '')
119+
if lang in SHELL_LANGS:
120+
pass
121+
elif lang == '' and SHELLY.match(first):
122+
pass # untagged, but opens with a command
123+
else:
124+
continue
125+
if PROMPT.match(first):
126+
continue # pasted session, not a command to run
127+
if '<<' in body:
128+
continue # heredoc body is data, not shell
129+
checked += 1
130+
with tempfile.NamedTemporaryFile('w', suffix='.sh', delete=False) as fh:
131+
fh.write('#!/bin/bash\n' + PLACEHOLDER.sub('PLACEHOLDER', body))
132+
tmp = fh.name
133+
try:
134+
if sc:
135+
r = subprocess.run(
136+
['shellcheck', '-s', 'bash', '--severity', 'error',
137+
'-f', 'gcc', tmp], capture_output=True, text=True)
138+
detail = (r.stdout.strip().split('\n')[0].split(':', 3)[-1].strip()
139+
if r.stdout.strip() else r.stderr.strip()[:80])
140+
else:
141+
r = subprocess.run(['bash', '-n', tmp],
142+
capture_output=True, text=True)
143+
detail = r.stderr.strip().split('\n')[0].split(': ', 1)[-1]
144+
finally:
145+
os.unlink(tmp)
146+
if r.returncode != 0:
147+
failures.append((path, line, detail, first.strip()[:60]))
148+
if not sc:
149+
print(" note: shellcheck not found, fell back to `bash -n`"
150+
" (syntax only -- weaker)")
151+
return checked, failures
152+
153+
154+
def check_twin_drift():
155+
def shell_bodies(path):
156+
return [b.strip() for _, _, b in code_blocks(path) if b.strip()]
157+
158+
drift = []
159+
shared = 0
160+
for fa_t, fb_t in TWINS:
161+
for page in TWIN_PAGES:
162+
fa, fb = fa_t.format(page), fb_t.format(page)
163+
if not (os.path.exists(fa) and os.path.exists(fb)):
164+
continue
165+
A, B = shell_bodies(fa), shell_bodies(fb)
166+
for a in A:
167+
if a in B:
168+
shared += 1
169+
continue
170+
best_ratio, best = 0.0, None
171+
for b in B:
172+
r = difflib.SequenceMatcher(None, a, b).ratio()
173+
if r > best_ratio:
174+
best_ratio, best = r, b
175+
if best_ratio >= 0.75:
176+
drift.append((fa, fb, best_ratio, a, best))
177+
return shared, drift
178+
179+
180+
def main():
181+
ok = True
182+
183+
checked, failures = check_syntax()
184+
print(f"shell blocks parsed : {checked}")
185+
if failures:
186+
ok = False
187+
print(f"FAILED : {len(failures)}\n")
188+
for path, line, detail, first in failures:
189+
print(f" {path}:{line}")
190+
print(f" {detail}")
191+
print(f" block starts: {first}")
192+
print("\n If the block is command OUTPUT rather than commands, tag it")
193+
print(" ```text or ```console instead of ```bash.")
194+
else:
195+
print(" all parse cleanly")
196+
197+
shared, drift = check_twin_drift()
198+
print(f"\nshared twin blocks : {shared}")
199+
if drift:
200+
ok = False
201+
print(f"DRIFTED : {len(drift)}\n")
202+
for fa, fb, ratio, a, b in drift:
203+
print(f" {fa}")
204+
print(f" {fb}")
205+
print(f" {ratio:.0%} similar but not identical -- fix landed in one tree only?")
206+
print(f" farmers: {a.splitlines()[0][:70]}")
207+
print(f" labs: {b.splitlines()[0][:70]}")
208+
else:
209+
print(" no drift between farmers/ and labs/ command blocks")
210+
211+
return 0 if ok else 1
212+
213+
214+
if __name__ == '__main__':
215+
sys.exit(main())

0 commit comments

Comments
 (0)