Skip to content

feat: report diagnostics for malformed PEP 723 inline script metadata - #1785

Merged
Stella Huang (StellaHuang95) merged 2 commits into
microsoft:mainfrom
StellaHuang95:stellahuang-microsoft-pep723-inline-script-research
Sep 15, 2026
Merged

Stella Huang (StellaHuang95) merged 2 commits into
microsoft:mainfrom
StellaHuang95:stellahuang-microsoft-pep723-inline-script-research

Conversation

@StellaHuang95

Copy link
Copy Markdown
Contributor

Part of #1602 (PEP 723 inline script env support). Does not close it — this covers only diagnostics for malformed metadata.

Problem

Malformed PEP 723 metadata is currently invisible. The parser collapses every failure to undefined, so a block with a typo — a missing closing # ///, a content line without the required space after #, a TOML syntax error — produces no CodeLens, no warning, nothing. The file just looks like ordinary Python and the user has no idea why the "Setup environment" action never appeared.

Worse, a malformed block and a file with no block were indistinguishable, so we couldn't have told the user even if we wanted to.

What this does

The parser now returns a discriminated result — valid / invalid / none — carrying structured problems with source ranges, and a new InlineScriptDiagnosticsPublisher surfaces them as squiggles.

Gated behind the existing python-envs.inlineScripts.enabled setting (default false, latched at activation). No Pylance or Python extension changes; no cross-team dependencies.


Spec conformance

Normative source: PyPA — Inline script metadata. Permalinks below are pinned to commit 9dd85f2, the latest commit touching that file, so line numbers can't drift.

I extracted every RFC 2119 keyword sentence from the spec source programmatically: 20 total — 11 MUST, 2 MUST NOT, 1 SHOULD, 7 MAY. The mapping below is against that complete set, not a sampling.

Diagnostics backed by an explicit spec MUST

Code Severity Spec text Strength Source
invalid-block-marker Error "blocks that MUST start with the line # /// TYPE… Block MUST end with the line # ///… The TYPE MUST only consist of ASCII letters, numbers and hyphens" MUST x3 L20-L26
invalid-content-line Error "Every line between these two lines MUST be a comment starting with #. If there are characters after the # then the first character MUST be a space" MUST x2 L28-L30
multiple-blocks Error "When there are multiple comment blocks of the same TYPE defined, tools MUST produce an error" MUST L54-L55
unterminated-block Warning "Unclosed blocks MUST be ignored" MUST L51-L52

unterminated-block is a warning, not an error, precisely because the spec says such blocks MUST be ignored — the file is valid Python and valid per spec. We only hint that the author probably meant to close it. See the scoping rule below.

Also enforced without needing a diagnostic:

Spec requirement Strength How we conform Source
"Tools MUST NOT read from metadata blocks with types that have not been standardized" MUST NOT type === 'script' filter in both the block regex and the opener scan L70-L71
"If they choose not to [respect the encoding declaration], they MUST process the file as UTF-8" MAY / MUST We do not honour the encoding declaration; we process as UTF-8 and strip BOM L57-L58
"Tools MAY choose to do a simple textual scan, rather than a full Python parse" MAY Textual scan, per the canonical regex L77
"the text specification takes precedence" over the canonical regex tiebreak Applied deliberately — see empty blocks below L67-L68

Deliberately NOT reported

These are conformance decisions, not oversights. Each is measured by a test.

Case Why we stay silent
A start line nested inside another block Spec says tools MAY produce an error — MAY, not MUST. We take the least-aggressive legal option. L51
Empty block (# /// script immediately followed by # ///) The canonical regex requires >=1 content line (+), but the prose doesn't. Spec says prose wins, so this is valid, empty metadata. BLOCK_RE's content group is * with a comment marking the deliberate deviation.
Unclosed # /// script appearing below real code Almost always a PEP 723 example inside a docstring or tutorial text, not a broken header. New headerRegionEnd() suppresses the warning at/after the first non-blank, non-# line. The spec explicitly says behaviour inside multi-line strings "is tool-dependent and should not be relied on".
# /// used as block content Spec's precedence rule plus its own embedded-C# example. Handled by regex backtracking; verified against the spec's literal example.
Divider comments (# /////////), ## ///, non-script types Not blocks.

Checks that are ours, not the spec's

Worth calling out explicitly so reviewers can push back:

invalid-field-type (Error) fires when dependencies isn't an array of strings, requires-python isn't a string, or tool isn't a table. The spec describes these types in prose — "dependencies: A list of strings…", "requires-python: A string…" (L99-L104) — but attaches no MUST to the container type. The only MUST in those bullets governs entry validity.

I kept it as an error because a non-array dependencies has no valid interpretation and the alternative is a confusing downstream resolver failure. But it is inference, not mandate. Happy to downgrade to a warning if reviewers prefer strict spec-only conformance.

invalid-toml (Error) — the spec never literally states the content is TOML; it's implied by the [tool] table semantics and the reference implementation's tomllib.loads(content).

Known gaps (not addressed here)

Two genuine spec MUSTs are not enforced. Verified by measurement — both parse as valid with no diagnostic today:

Spec text Source Status
"Each entry MUST be a valid dependency specifier" (PEP 508) L99-L101 Not validated — dependencies = ["!!! not a specifier !!!"] accepted silently
"The value of this field MUST be a valid version specifier" (PEP 440) L102-L104 Not validated — requires-python = "totally-not-a-version" accepted silently

Both need real grammar implementations and would meaningfully expand scope. Deferred intentionally; happy to file a follow-up issue.


Behaviour change reviewers must notice

This PR is not purely additive. One existing behaviour changes:

# /// script
# ///

Before: parsed as no metadata -> no CodeLens.
After: parses as valid, empty metadata -> the setup CodeLens now appears.

That follows from the spec's "text takes precedence" tiebreak, and I believe it's correct, but it does affect provisioning and not just diagnostics. Everything else in this PR is diagnostics-only and leaves the provisioning path byte-identical — I verified that with a harness running the old and new parsers side by side over 20 scenarios.


Implementation notes

src/common/inlineScript/metadata.ts — the parser rework.

  • Result is now { kind: 'valid' | 'invalid' | 'none', problems, metadata? }.
  • New independent opener scan (findScriptOpeners) — this is what makes "malformed block" distinguishable from "no block". The canonical regex alone can't tell them apart.
  • toSourceRange is the single offset-to-line/character translation point. Reviewers should focus here for range correctness.
  • tomlErrorSourceRange recovers line/col from @iarna/toml errors that were previously discarded, so the squiggle underlines the actual offending text instead of the whole block.

src/features/inlineScript/diagnostics.ts — the publisher.

  • Publishes on open, save, and debounced change (300 ms, via the existing createSimpleDebounce helper). Debouncing matters here: without it we'd flash "missing closing marker" at someone who is still mid-edit.
  • createSimpleDebounce(ms, cb).trigger() takes no arguments and owns one timer, so a shared instance would let documents clobber each other — hence the per-URI pending map.
  • Clears on close, delete, rename, and dispose, so no stale squiggles survive.
  • Scope: file: scheme plus .py extension only.

Validation reads the live editor buffer, so squiggles match what's on screen; provisioning keeps reading the saved file. Both call the same parser, so the two views can't disagree about what "valid" means.

Edge cases handled

  • BOM and CRLF — BOM stripped, \r\n? normalized to \n before matching. Note JS regex . doesn't match \r while Python's does, so the canonical regex can't be used verbatim.
  • Ranges under BOM/CRLF — the bulk of the test suite. Every range assertion runs across four variants: LF, CRLF, BOM+LF, BOM+CRLF.
  • 8 KiB header slice — diagnostics apply sliceHeaderBytes exactly as the provisioning path does, so the two can't diverge on large files.
  • Adjacent blocks merge# /// is itself a legal content line, so back-to-back blocks are consumed as one. multiple-blocks correctly requires a non-content line between them.

Known limitation

Indented markers ( # /// script) are silent. The spec anchors markers at column 0, so an indented one isn't a block at all. Documented in the parser.


Testing

  • 52 new unit tests (31 parser/range, 21 publisher).
  • Full suite: 2280 passing, 0 failing, 6 pending, on a fresh npm run compile-tests build.
  • npm run lint clean, tsc clean, Prettier clean.

Note for anyone re-running: npm run unittest executes ./out/test/**/*.unit.test.jscompiled output. Run npm run compile-tests first or you'll silently validate stale code.

Manual verification

Set python-envs.inlineScripts.enabled: true and reload the window (the flag is latched at activation). Then try:

# /// script
# requires-python = ">=3.11"
# dependencies = ["requests"]

-> warning: missing closing # ///

# /// script
#requires-python = ">=3.11"
# ///

-> error: content line must be exactly # or start with #

# /// script
# dependencies = [
# ///

-> error: TOML error, underlining the offending token

Review focus

  1. toSourceRange correctness — every range flows through it; BOM/CRLF offsets are the likeliest bug.
  2. Is invalid-field-type too aggressive? It's the one error not backed by a spec MUST.
  3. The empty-block behaviour change — it alters provisioning, not just diagnostics.
  4. Debounce lifecycle — per-URI map, disposal on close/delete/rename.

Malformed inline script metadata was previously invisible. A block with a
typo -- a missing closing `# ///`, a content line without the required
space after `#`, a TOML syntax error -- collapsed to `undefined` in the
parser, so no CodeLens appeared and nothing explained why. The file
simply looked like ordinary Python.

The parser now returns a discriminated result (`valid` / `invalid` /
`none`) carrying structured problems with source ranges, and a new
`InlineScriptDiagnosticsPublisher` surfaces them as squiggles on open,
save, debounced change (300ms) and clears on close, delete, rename and
dispose.

Diagnostics are validated against the live editor buffer so squiggles
match what is on screen, while provisioning continues to read the saved
file. Both share one parser, so the two views cannot disagree about what
"valid" means.

Gated behind the existing `python-envs.inlineScripts.enabled` setting.
No Pylance or Python extension changes.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@rchiodo

Rich Chiodo (rchiodo) commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

🔒 Automated review in progress — Rich Chiodo (@rchiodo) is auto-reviewing this PR.

Comment thread src/common/inlineScript/metadata.ts Outdated
Comment thread src/features/inlineScript/diagnostics.ts
Comment thread src/common/inlineScript/metadata.ts
Comment thread src/test/common/inlineScript/metadataDiagnostics.unit.test.ts
Comment thread src/test/features/inlineScript/diagnostics.unit.test.ts Outdated
@rchiodo

Copy link
Copy Markdown
Contributor

Result: 🔴 could-not-verify

Verification details

Verification: The relevant tests could not be fully run in the isolated environment; this review is not fully verified.

Summary: Targeted parser regression and diagnostics publisher tests were identified, including all 52 tests added by the PR. Execution could not begin because the container has neither `node` nor `npm`; the targeted compile-and-test command exited 127. Consequently, no meaningful test passed or failed, and confidence is low.

Test runs: 2 not run

  • ⚠️ Not run | Inline script parser and diagnostics targeted unit tests | npm run compile-tests && node ./node_modules/mocha/bin/mocha.js --config=./build/.mocha.unittests.json out/test/common/inlineScript/metadata.unit.test.js out/test/common/inlineScript/metadataDiagnostics.unit.test.js out/test/features/inlineScript/diagnostics.unit.test.js
  • ⚠️ Not run | Node dependency and test-discovery preflight | printf '%s\n' '== toolchain ==' && node --version && npm --version && printf '%s\n' '== dependency state ==' && if [ -d node_modules ]; then echo 'node_modules=present'; else echo 'node_modules=missing'; fi && if [ -f package-lock.json ]; then echo 'lockfile=package-lock.json'; elif [ -f pnpm-lock.yaml ]; then echo 'lockfile=pnpm-lock.yaml'; else echo 'lockfile=missing'; fi && printf '%s\n' '== relevant scripts ==' && node -e "const p=require('./package.json'); for (const k of ['compile-tests','unittest','lint']) console.log(k+'='+p.scripts[k])" && printf '%s\n' '== mocha config ==' && cat build/.mocha.unittests.json && printf '%s\n' '== targeted source/compiled tests ==' && find src/test/common/inlineScript src/test/features/inlineScript -maxdepth 1 -type f ( -name 'metadata*.unit.test.ts' -o -name 'diagnostics.unit.test.ts' ) -print && find out/test/common/inlineScript out/test/features/inlineScript -maxdepth 1 -type f ( -name 'metadata*.unit.test.js' -o -name 'diagnostics.unit.test.js' ) -print 2>/dev/null || true
⚠️ Inline script parser and diagnostics targeted unit tests diagnostic output
/bin/sh: 1: npm: not found
[container exit=127]
⚠️ Node dependency and test-discovery preflight diagnostic output
== toolchain ==
/bin/sh: 1: node: not found

@rchiodo Rich Chiodo (rchiodo) added the review-auto:changes-requested Automated review: posted blocking findings to address. label Sep 14, 2026
- Report a bad content line instead of a bogus "missing closing marker"
  when the closing `# ///` is present. A blank line between fields is the
  common trigger and now gets a message that names the real problem.
  Blocks below the leading comment region stay silent as before, so
  documentation examples are unaffected.
- Cancel debounced validations for descendants of a deleted or renamed
  folder. Previously only published entries were swept, so a file edited
  within the debounce window could gain a squiggle after it was gone.
- Rename the parse discriminant `valid` to `parsed`: it reports that
  metadata is usable, not that the input was problem-free, and it can
  carry error-severity problems from a second malformed block.
- Assert the exact TOML detail and the exact localized diagnostic
  messages so a code-to-message mis-mapping cannot pass unnoticed.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@rchiodo

Copy link
Copy Markdown
Contributor

Result: 🔴 could-not-verify

Verification details

Verification: Isolated verification observed failures that were not classified as caused by this PR: Environment and test discovery. The relevant tests could not be fully run in the isolated environment; this review is not fully verified.

Summary: Verification could not proceed because the disposable container has no Node.js; the discovery command exited 127 with `node: not found`. Consequently, TypeScript compilation and the targeted parser and diagnostics suites were not run. Source inspection found 61 newly added tests across two files, covering parser ranges, malformed metadata, diagnostics publishing, debouncing, and cleanup. Confidence is limited without executable results.

Test runs: 1 failed, 3 not run

  • Failed | unrelated to this PR | Environment and test discovery | node -e "const fs=require('fs'),p=require('./package.json'); console.log('node='+process.version); console.log('scripts='+JSON.stringify(p.scripts,null,2)); console.log('node_modules='+fs.existsSync('node_modules')); console.log('compiled_parser_test='+fs.existsSync('out/test/common/inlineScript/metadataDiagnostics.unit.test.js')); console.log('compiled_publisher_test='+fs.existsSync('out/test/features/inlineScript/diagnostics.unit.test.js'))" && printf '\nChanged files:\n' && git diff --name-status HEAD~1...HEAD && printf '\nNew test counts:\n' && grep -cE '^[[:space:]]*test(' src/test/common/inlineScript/metadataDiagnostics.unit.test.ts src/test/features/inlineScript/diagnostics.unit.test.ts
  • ⚠️ Not run | TypeScript test compilation | npm run compile-tests
  • ⚠️ Not run | Inline script metadata parser suites | npm run unittest -- --grep "inlineScriptMetadata"
  • ⚠️ Not run | Inline script diagnostics publisher suite | npm run unittest -- --grep "^InlineScriptDiagnosticsPublisher"
Environment and test discovery diagnostic output
/bin/sh: 1: node: not found
[container exit=127]
⚠️ TypeScript test compilation diagnostic output
Not run because the container lacks Node.js.
⚠️ Inline script metadata parser suites diagnostic output
Not run because Node.js was unavailable and tests could not be compiled.
⚠️ Inline script diagnostics publisher suite diagnostic output
Not run because Node.js was unavailable and tests could not be compiled.

@rchiodo Rich Chiodo (rchiodo) left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved via Review Center.

@rchiodo Rich Chiodo (rchiodo) added review-auto:approved Automated review: no blocking findings (approval posted). and removed review-auto:changes-requested Automated review: posted blocking findings to address. labels Sep 15, 2026
@StellaHuang95
Stella Huang (StellaHuang95) merged commit 17cd8ba into microsoft:main Sep 15, 2026
48 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature-request Request for new features or functionality review-auto:approved Automated review: no blocking findings (approval posted).

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants