Repository navigation
fix(ci): pair gate resolves declared Pairs-with on an empty surface; record gate strips comments; chart freeze hardening #7526
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -184,9 +184,120 @@ def _strip_bom(text: str) -> str: | |
| return text.lstrip("\ufeff") | ||
|
|
||
|
|
||
| # 🚨 A COMMENT is not a parameter (MeshWeaver#7366). `split_params` splits on top-level commas | ||
| # and `parse_param` takes the last token as the name, so a `//` line between two parameters | ||
| # whose prose holds a comma became a PHANTOM parameter, and a comment ending in an identifier | ||
| # was swallowed into the next parameter's TYPE. Measured on core#5077: `UserOnboardingRequest` | ||
| # read as 8 -> 10 parameters for a real 7 -> 8, with five lines of prose printed as the type of | ||
| # `Locale`. Rewording such a comment then read as a type change on a record whose signature did | ||
| # not move — and, worse, a comment GAINING a comma while a real parameter left kept the count | ||
| # equal. So comments go before anything is counted, on BOTH sides of the comparison, because both | ||
| # pass through here. | ||
| # | ||
| # String and char literals are copied verbatim (a `//` inside `"https://…"` is not a comment); | ||
| # `@"…"` verbatim strings use `""` as their only escape, and a raw literal opened by N >= 3 | ||
| # quotes runs to the next N quotes (a four-quote literal may hold `"""`). An interpolated `$"…"` | ||
| # string's `{…}` holes are scanned as CODE, so a nested literal such as | ||
| # `$"{string.Concat("/*", "x")}"` neither ends the outer string nor opens a comment. A literal | ||
| # the scanner mis-bounds is not harmless: text it leaves outside a string can open a phantom | ||
| # `/*` that swallows every record after it, and an arity change there would pass. Newlines inside | ||
| # a block comment are kept so nothing downstream sees two tokens fused into one. | ||
| def _skip_hole(text: str, j: int) -> int: | ||
| """`j` is just past an interpolation hole's `{`; returns the index just past its `}`.""" | ||
| n, depth = len(text), 0 | ||
| while j < n: | ||
| c = text[j] | ||
| if c in "\"'": | ||
| j = _skip_literal(text, j) | ||
| continue | ||
| if c in "{([": | ||
| depth += 1 | ||
| elif c in "})]": | ||
| if depth == 0 and c == "}": | ||
| return j + 1 | ||
| depth -= 1 | ||
| j += 1 | ||
| return n | ||
|
|
||
|
|
||
| def _skip_literal(text: str, i: int) -> int: | ||
| """`text[i]` opens a string or char literal; returns the index just past its end.""" | ||
| n = len(text) | ||
| if text[i] == "'": | ||
| j = i + 1 | ||
| while j < n: | ||
| c = text[j] | ||
| if c == "\\": | ||
| j += 2 | ||
| continue | ||
| if c in "'\n": | ||
| return j + 1 | ||
| j += 1 | ||
| return n | ||
| p = i | ||
| while p > 0 and text[p - 1] in "$@": | ||
| p -= 1 | ||
| prefix = text[p:i] | ||
| interpolated, verbatim = "$" in prefix, "@" in prefix | ||
| run = 0 | ||
| while i + run < n and text[i + run] == '"': | ||
| run += 1 | ||
| if run >= 3 and not verbatim: | ||
| close = text.find('"' * run, i + run) | ||
| return n if close == -1 else close + run | ||
|
Comment on lines
+245
to
+247
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same answer as the sibling thread. A raw interpolated string whose hole holds another raw literal that contains |
||
| j = i + 1 | ||
| while j < n: | ||
| c = text[j] | ||
| if verbatim: | ||
| if c == '"': | ||
| if j + 1 < n and text[j + 1] == '"': | ||
| j += 2 | ||
| continue | ||
| return j + 1 | ||
| else: | ||
| if c == "\\": | ||
| j += 2 | ||
| continue | ||
| if c in '"\n': | ||
| return j + 1 | ||
| if interpolated and c == "{": | ||
| if j + 1 < n and text[j + 1] == "{": | ||
| j += 2 | ||
| continue | ||
| j = _skip_hole(text, j + 1) | ||
| continue | ||
| j += 1 | ||
| return n | ||
|
|
||
|
|
||
| def _strip_comments(text: str) -> str: | ||
| out: list[str] = [] | ||
| i, n = 0, len(text) | ||
| while i < n: | ||
| ch = text[i] | ||
| if ch == "/" and i + 1 < n and text[i + 1] == "/": | ||
| end = text.find("\n", i) | ||
| i = n if end == -1 else end | ||
| continue | ||
| if ch == "/" and i + 1 < n and text[i + 1] == "*": | ||
| end = text.find("*/", i + 2) | ||
| stop = n if end == -1 else end + 2 | ||
| out.append(" " + "\n" * text.count("\n", i, stop)) | ||
| i = stop | ||
| continue | ||
| if ch in "\"'": | ||
| stop = _skip_literal(text, i) | ||
| out.append(text[i:stop]) | ||
| i = stop | ||
| continue | ||
| out.append(ch) | ||
| i += 1 | ||
| return "".join(out) | ||
|
|
||
|
|
||
| def records_in(source: str) -> dict[str, list[Param]]: | ||
| """Every public record's primary-constructor parameter list, by record name.""" | ||
| source = _strip_bom(source) | ||
| source = _strip_comments(_strip_bom(source)) | ||
| found: dict[str, list[Param]] = {} | ||
| for m in RECORD_RE.finditer(source): | ||
| open_idx = source.index("(", m.end() - 1) | ||
|
|
@@ -376,6 +487,55 @@ def check(label: str, cond: bool) -> None: | |
| check("an attribute is stripped, not counted", len(a) == 2 and a[0].name == "X") | ||
| check("a params modifier is stripped", a[1].name == "Rest") | ||
|
|
||
| # ── comments inside a parameter list are not parameters (#7366) ───────────────────── | ||
| # The core#5077 shape: line comments between parameters, holding commas, an em dash, a | ||
| # trailing identifier and a `//` inside a string default that must SURVIVE. | ||
| commented = ( | ||
| "public sealed record Onboarding(\n" | ||
| " string Username,\n" | ||
| " string Email,\n" | ||
| " // The viewer's language (\"de\"), from the onboarding form's language picker —\n" | ||
| " // which itself defaults to the browser's, so the point of use reads it anyway.\n" | ||
| " string? Locale = null,\n" | ||
| " /* a block comment, with a comma,\n" | ||
| " spanning lines */ string Home = \"https://example.com/a,b\",\n" | ||
| " // not learning it, and is pinned to no learning path.\n" | ||
| " bool IsPlatformBootstrap = false);\n" | ||
| ) | ||
| oc = records_in(commented).get("Onboarding") or [] | ||
| check("comment text is not counted as parameters (#7366: real arity is 5)", | ||
| [p.name for p in oc] == ["Username", "Email", "Locale", "Home", "IsPlatformBootstrap"]) | ||
| check("…and a comment is not swallowed into the next parameter's TYPE", | ||
| [p.type for p in oc] == ["string", "string", "string?", "string", "bool"]) | ||
| reworded = commented.replace("so the point of use reads it anyway", "so callers, again, read it") | ||
| check("rewording a comment inside a parameter list is NOT a signature change", | ||
| not compare("Onboarding", oc, records_in(reworded).get("Onboarding") or [], "x.cs")) | ||
| dropped = commented.replace(" string Email,\n", " // Email moved, see the profile record,\n") | ||
| check("a real parameter leaving while a comment gains a comma is still an arity change", | ||
| [x.kind for x in compare("Onboarding", oc, | ||
| records_in(dropped).get("Onboarding") or [], "x.cs")] == ["arity"]) | ||
| check("a `//` inside a string literal is not a comment", | ||
| records_in('public record U(string Url = "http://x", int N = 1);').get("U") is not None | ||
| and [p.name for p in records_in('public record U(string Url = "http://x", int N = 1);')["U"]] | ||
| == ["Url", "N"]) | ||
| check("a commented-OUT record is not a record", | ||
| "Gone" not in records_in("// public record Gone(int A);\npublic record Here(int A);")) | ||
| # A mis-bounded literal leaves text outside any string, where a `/*` becomes a phantom | ||
| # comment that swallows every record after it — and an arity change there would pass. | ||
| four_quote = 'var s = """"\nhas """ /* inside\n"""";\npublic record After(int A, int B);\n' | ||
| check("a four-quote raw literal holding three quotes does not hide the record after it", | ||
| [p.name for p in records_in(four_quote).get("After") or []] == ["A", "B"]) | ||
| nested = ('var s = $"{string.Concat("/*", "x")}";\npublic record After(int A, int B);\n' | ||
| 'var t = "*/";\n') | ||
| check("a literal nested in an interpolation hole does not open a comment", | ||
| [p.name for p in records_in(nested).get("After") or []] == ["A", "B"]) | ||
| check("…and `{{` in an interpolated string is an escaped brace, not a hole", | ||
| [p.name for p in records_in('var s = $"{{ /* }}";\npublic record After(int A);\n') | ||
| .get("After") or []] == ["A"]) | ||
| check("a verbatim string opening with an escaped quote is not a raw literal", | ||
| [p.name for p in records_in('var s = @"""x"" /*";\npublic record After(int A);\n') | ||
| .get("After") or []] == ["A"]) | ||
|
|
||
| # ── the transitional allowance, end to end through run() ───────────────────────── | ||
| # | ||
| # 🚨 These call run() on a real repository rather than the classifier in isolation. A guard | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not changing this in round 3. It is a real lexer gap, but you only reach it with a block comment that holds a
}INSIDE an interpolation hole and is followed by a literal/*in that same hole. That is far outside how this tree writes C#. Extending the hand lexer to cover comments in holes is hardening beyond this PR's scope, which is the comment stripping for ordinary code that #7366 asked for. I filed it to triage together with the raw-literal case on the sibling thread (rbuergi/Feedback/20261029-record-gate-lexer-comments-and-raw-literals-in-holes), with both repro shapes and a regression-case plan.