feat: check that zh and ja pages mirror the English page's structure - #1042
Conversation
The editor test judged whole pages, so a round that changed three paragraphs on a release branch got a verdict on the page's standing debt. Its dispatch now names the changed sections, the judge still reads the whole page, and marks inside and outside the change are reported apart, with the verdict on this round's work. The judge reads the whole style guide, and a mark can name the rule it breaks. A translation test gives zh and ja pages the same fresh-judge check beside the English source, against the language's guide and the glossary; S7 runs it instead of exempting translations. Dispatch counts are agreed with the owner before any drafter, editor, or reader is sent; one per page is a choice, not the default. Reader-test findings are relayed and proposed per item before anything reaches the page. The research summary has a shape of its own, in references/research-summary.md: a contract that the sheet is the ceiling and not a checklist, sections opened by the reader's question, background facts marked, and the two closing lists. The session owns what the page does not carry; the drafter keeps room to leave out sentence by sentence, and may read the pages this one links to. S6 looks for an existing translation in the sibling audience tree before translating. Style guide: switches are written as the change the reader might make, and a cross-reference keeps the reader as its subject.
A paragraph present in the zh and ja pages and absent from the English one passed every linter, the link check, and two merged commits before an external reviewer caught it; an anchor divergence in the same release passed hunk-count parity. Neither check looks across languages. tools/check-parity.py splits each English page at its headings and compares every section with the zh and ja twins: heading count and level, then paragraphs, list items, code blocks, table rows, components, tabs, and anchor ids. Heading text is translated and not compared; the translation disclaimer is skipped. It ends with PARITY OK or one line per mismatch and exit 1. On the members page as it stood before the 1.17.1 retranslation it reports 22 mismatches; the shipped page passes. The format-check skill runs it after the two linters for the English files in scope. Across the corpus today it reports 407 mismatches on 141 of 291 pages, so a mismatch outside the sections a round touched is reported to the owner as pre-existing rather than fixed in passing.
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Automations to automatically generate PRs for you. |
There was a problem hiding this comment.
🟡 Changes recommended
Critical parity-checker coverage and pre-existing-mismatch handling issues remain unresolved.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds cross-language structural parity checking for English, Chinese, and Japanese documentation, alongside updates to writing and review workflows.
Changes:
- Adds
tools/check-parity.pyfor structural comparisons. - Integrates parity, translation, reader, and editor checks.
- Updates style, research-summary, and drafting guidance.
File summaries
| File | Description |
|---|---|
writing-guides/style-guide.md |
Adds switch/default and cross-reference guidance. |
writing-guides/index.md |
Updates verification workflow steps. |
tools/check-parity.py |
Implements cross-language structure checks. |
.claude/skills/dify-docs-write/SKILL.md |
Updates the documentation workflow. |
.claude/skills/dify-docs-write/references/research-summary.md |
Defines the research-summary format. |
.claude/skills/dify-docs-write/references/drafting-turn.md |
Clarifies drafting constraints. |
.claude/skills/dify-docs-translation-test/SKILL.md |
Adds translation review procedures. |
.claude/skills/dify-docs-reader-test/SKILL.md |
Adds multi-page reader-test guidance. |
.claude/skills/dify-docs-format-check/SKILL.md |
Integrates parity checking. |
.claude/skills/dify-docs-editor-test/SKILL.md |
Updates editor-test dispatch and scope handling. |
Review details
Suppressed comments (4)
.claude/skills/dify-docs-reader-test/SKILL.md:16
- Allowing one reader to process multiple pages sequentially breaks the clean-context/first-time-reader premise: after page A, the same agent has learned terminology and navigation that can make page B appear self-contained. Use a fresh subagent per page, or explicitly reset the agent context between pages; the current prompt cannot provide that isolation.
2. **Agree the dispatch with the owner**: how many readers, and which pages each reads. A page written or rewritten whole gets its own reader. A round that changed parts of several pages can send them to one reader, who reads each whole; the reader is never told what changed, because a first-time reader does not know either. One reader per page is a choice, not the default. When no reviewer is in the session, state the dispatch in the report or PR description and proceed.
.claude/skills/dify-docs-write/references/research-summary.md:66
drafting-turn.mdnow makes the summary a hard ceiling ('Nothing off it goes on the page'), but this example explicitly approves adding a consequence the summary did not state. A drafter following both instructions cannot tell whether this reader-oriented consequence is allowed, and could either add an unverified fact or omit the intended judgment. Clarify that the sentence is an inference from listed facts, or include the consequence in the verified summary.
The two background facts stayed off the page. The fix for each reason went in whole, because that is the judgment the reader came for. The last bullet became a sentence with a consequence the summary did not state, because the drafter asked what the reader would do with the fact.
.claude/skills/dify-docs-write/references/research-summary.md:14
- The contract says every fact below is verified, but the template's own
Not on this pagelist explicitly permits anUnverifiedentry, and the writing workflow requires unverified claims to be reported. Clarify that only facts proposed for inclusion must be verified, with unverified claims recorded separately, so the researcher is not told both to include and exclude them.
Every fact below is verified. Add no fact that is not on this sheet, and
do not state every fact that is: a fact goes on the page because the
reader would ask for it at that section. Facts marked "background" are
here so you understand the product; the reader would not ask for them, so
they stay off the page.
tools/check-parity.py:138
- The parity input filter accepts only
.mdx, while the format-check procedure explicitly treats both.mdxand.mdas in-scope and passes those paths here. A Markdown page is silently omitted (or yieldsno English pageswhen it is the only input), so its zh/ja twin is never checked. Support.mdconsistently in the filter and--allpath, or reject unsupported inputs explicitly.
pages = [p for p in pages if p.is_file() and p.suffix == ".mdx" and (REPO / "en") in p.parents]
- Files reviewed: 10/10 changed files
- Comments generated: 6
- Review effort level: Lite
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
A reader group holds only pages a reader would reach in sequence, in navigation order, with one persona. The translation test's term tag no longer reads as marking every correct translation, and its rule tag covers translation rules only. Sibling-translation reuse is scoped to use-dify pages and requires the sibling's English to match this page's, since the two trees can sit on different releases. The summary's verified guarantee covers facts under section headings; the closing list may record unverified claims so they are reported, never stated. The ceiling bounds product facts, and what the reader should do with a fact is the drafter's to say. The UI-visible list keeps what the style guide says to repeat. The outline is the summary's question-headed sections; the index notes the API-spec exception.
…ents Every mismatch failed the run, so a page carrying older drift could never print PARITY OK however clean the round's own sections were. With --base, the same comparison runs at that ref and only new mismatches count; the rest are listed as pre-existing. The format-check step passes every changed page in any language, since a zh or ja path is checked through its English twin, so a translation-only change is covered. Headings indented inside components are now read, component names are compared as a sequence rather than a count, id attributes on tags count as anchors, and .md pages are accepted.
…e parity check A component inside a table cell or a list item was invisible to the check, so removing one from a translation passed. Tags are now collected anywhere on a line outside code fences. A page argument that is not a page under en/, zh/, or ja/ is a usage error rather than a silent skip.
The glossary's UI Labels rows can be stale, and the translation judge has no way to know which. General terms are still checked against the glossary; a UI label is compared with the label the English page names, which the terminology check verifies against the product.
There was a problem hiding this comment.
🟡 Changes recommended
The parity checker has unresolved coverage and baseline-handling defects, including one critical finding.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (9)
tools/check-parity.py:40
HEADING_REonly recognizes Markdown#headings, but--allincludes the custom/frame pages whose structure uses HTML<h1>/<h2>tags (for example,en/home.mdxanden/cloud/use-dify/getting-started/introduction.mdx). Those lines fall through to the generic tag branch, so adding, removing, or changing the level of an HTML heading in a translation leaves the section count and levels unchanged and passes parity. Parse HTML heading tags as sections too, or explicitly exclude these page modes from this check.
HEADING_RE = re.compile(r"^\s*(#{1,6})\s+(.*?)\s*$")
CUSTOM_ID_RE = re.compile(r"\{#([\w-]+)\}")
TAG_ID_RE = re.compile(r"""\bid=["']([\w-]+)["']""")
tools/check-parity.py:158
- Unlike
read_working, this subprocess relies on the process locale to decode Git output because no encoding is specified. On a non-UTF-8 locale, reading any Chinese or Japanese base page can raiseUnicodeDecodeErrorand abort the parity check; set the subprocess encoding explicitly to UTF-8.
proc = subprocess.run(
["git", "-C", str(REPO), "show", f"{ref}:{rel}"], capture_output=True, text=True
)
tools/check-parity.py:95
- Anchor extraction is limited to the opening line of a component tag. The docs already use multiline tags such as
<Accordionfollowed by attribute lines, so anidon a later line is counted as prose and never added toanchors; changing or removing that stable component ID between languages will therefore pass parity. Parse the complete multiline tag before extracting its attributes.
c = COMPONENT_RE.match(line)
if c:
cur.components.append(c.group(1))
cur.anchors.extend(TAG_ID_RE.findall(line))
in_para = False
tools/check-parity.py:43
- This separator regex only checks the beginning of the first cell, so a legitimate data row such as
| ---draft | ... |is classified as the separator and omitted fromtable rows. A row added or removed in that form can pass without a mismatch; require every cell to match the separator pattern through the end of the row.
TABLE_SEP_RE = re.compile(r"^\s*\|?\s*:?-{3,}")
tools/check-parity.py:88
- Only the number of table rows is stored; cell boundaries are discarded. A translation can add a column or omit a cell in every row and still produce the same
table rowscount, so a real table-shape divergence is reported asPARITY OK. Track each row's cell count (or another normalized row shape) and compare it per section.
if TABLE_ROW_RE.match(line):
if not TABLE_SEP_RE.match(line):
cur.counts["table rows"] += 1
tools/check-parity.py:103
list itemsrecords only a total count and loses indentation and marker type. Re-nesting an item, or changing a flat list into an item with a nested subitem, leaves the count unchanged and lets the rendered structure diverge without an issue. Record the normalized list nesting/block shape rather than only the total.
if LIST_RE.match(line):
cur.counts["list items"] += 1
in_para = False
tools/check-parity.py:207
- The baseline subtraction is based on exact output strings, but each section issue embeds its numeric
section {i}(and its counts). Adding or reordering a heading before an untouched, pre-existing mismatch changes that string, so the old drift is classified as new and can block the structural edit. Compare normalized mismatch identities after mapping sections, rather than subtracting raw rendered lines.
seen = Counter(before)
tools/check-parity.py:198
- The format-check instructions derive
<changed pages...>fromgit diff --name-only, which includes deleted files. If an English page and both language twins are intentionally removed together, no working English file exists and this emitsmissing English page, so the deletion cannot pass parity. Distinguish a page deleted in all languages from a missing English twin before adding an issue.
en_text = read_working(en)
if en_text is None:
new.append(f"{en}: missing English page")
continue
tools/check-parity.py:108
- Indented continuation lines of a Markdown list item are classified as paragraphs by the fallback below. For example,
en/cli/integrate-agents/overview.mdxwraps list-item text onto indented lines while its zh/ja twins keep the text on the marker line; these equivalent list structures produce different paragraph counts and parity noise. Track list-item continuations/nested blocks or parse the Markdown structure instead of treating every non-marker line as a new paragraph.
if not in_para:
cur.counts["paragraphs"] += 1
in_para = True
- Files reviewed: 10/10 changed files
- Comments generated: 3
- Review effort level: Lite
…the summary contract The translation judge could only quote sentences in the draft, so an English sentence or section with no counterpart went unmarked; it is now marked as meaning, quoting the English. The summary's verified guarantee covers the question-headed sections, and the closing lists hold what stays off the page, unverified claims included. The research skill's Phase 3 says its record is research output, and that S3 writes the drafter's summary from it.
… headings A misspelled or absent base ref was treated as no baseline, so every mismatch counted as new; the ref is verified once and a bad one is a usage error. A heading-count mismatch skipped every section, which with a base ref hid new drift inside sections that still aligned; sections are compared in order as far as they align. Component tags inside heading lines, where plan badges live, are now part of the section's component sequence.
There was a problem hiding this comment.
🟡 Changes recommended
The parity checker has unresolved review findings, including a critical path-validation issue.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (11)
Previously missed (1) — in code that hasn't changed since the last review.
tools/check-parity.py:84
- This branch creates the new section and immediately continues without recording tags from the heading line. Existing pages put Mintlify components in headings (for example
<Badge>inen/cli/common-tasks.mdx:77), so adding or removing that component in a translation is invisible to the advertised component-parity check. ExtractINLINE_TAG_REandTAG_ID_REfrom the heading before continuing.
tools/check-parity.py:162
read_attreats an invalid or unfetched base ref exactly like a missing file. With the documented--base origin/main, a missing ref makesbeforeempty, so pre-existing drift is reported as newly introduced (or a clean current tree reportsPARITY OKwithout any baseline comparison). Validate the base ref once and fail with a clear usage error instead of silently weakening the gate.
def read_at(ref: str, rel: str) -> str | None:
proc = subprocess.run(
["git", "-C", str(REPO), "show", f"{ref}:{rel}"], capture_output=True, text=True
)
tools/check-parity.py:107
- The generic custom-anchor extraction is after the
LIST_REearly return, and noTAG_ID_REextraction occurs on list lines. Therefore an explicit{#...}or HTMLidattached to a list item is invisible to the parity check, allowing that anchor to diverge without an issue. Extract both anchor forms in the list branch before continuing.
in_para = False
continue
if LIST_RE.match(line):
cur.counts["list items"] += 1
tools/check-parity.py:145
- This comparison deliberately sorts the anchor lists, so it ignores anchor order. If a section contains two explicit anchors and a translation moves them to the other blocks, the same IDs still compare equal even though deep links land on different content. Compare the anchors in document order (or associate each anchor with its block) rather than sorting them.
)
tools/check-parity.py:96
TAG_ID_REis applied only to the line containing the opening component. The repository uses multiline JSX tags (for example,getting-started-dify-plugin.mdx:40-44), so anidplaced on a continuation line is never added toanchors; a stable-anchor divergence in that form will pass this check. Parse the complete opening tag before extracting its attributes.
in_para = False
continue
if COMPONENT_RE.match(line):
tools/check-parity.py:217
- The baseline filter matches fully formatted strings that include the numeric
section ilabel. Adding a matching heading to English and its twin before an untouched pre-existing mismatch changes that label, so the same old mismatch is treated as new instead of being listed underpre-existing. Compare stable mismatch identities independent of section numbering, or retain section identity across the baseline/current parse.
new.append(f"{en}: missing English page")
continue
rest = en.split("/", 1)[1]
now = compare_texts(en, en_text, {lang: read_working(f"{lang}/{rest}") for lang in TWINS})
if args.base:
tools/check-parity.py:103
- This branch discards every line that starts with
<after only recordingidattributes, so standalone raw HTML content such as<a href="...">Learn more</a>(used indevelop-plugin/getting-started/getting-started-dify-plugin.mdx:47) contributes no paragraph or component count. Removing that link from zh/ja therefore passes parity. Distinguish wrapper tags from content-bearing HTML and count/compare the latter as a block.
continue
cur.components.extend(INLINE_TAG_RE.findall(line))
if line.lstrip().startswith("<"):
cur.anchors.extend(TAG_ID_RE.findall(line))
tools/check-parity.py:183
*.md*also matches non-page files such aspage.md.bakandpage.mdx~. With--all, any such artifact underen/is treated as a page and then produces spurious missing-twin issues. Restrict this scan to the documented.mdand.mdxsuffixes.
ok = subprocess.run(
tools/check-parity.py:105
- All list markers are collapsed into the single
list itemscounter, so an English numbered procedure and a translated unordered list with the same item count compare equal. That changes the structure and sequence semantics while producing no parity issue. Track ordered and unordered markers separately (ideally preserving their sequence).
continue
tools/check-parity.py:69
FENCE_REtoggles on every fence marker, regardless of which marker opened the block. A valid code block can contain the other marker (for example, a ``` block containing a~~~line); after that line, headings/components in the remainder of the code are parsed as document structure, so parity results depend on code-sample contents. Track the opener's marker and length and only close on a matching fence.
cur = out[-1]
if FENCE_RE.match(line):
if not in_fence:
cur.counts["code blocks"] += 1
tools/check-parity.py:111
- After a list item resets
in_paratoFalse, any indented continuation line reaches this block and is counted as a new paragraph. Markdown treats a continuation without a blank line as part of the list item's paragraph, so language-specific wrapping can create parity mismatches even when the structure mirrors. Track list-item continuation/indentation instead of resetting paragraph state this way.
continue
cur.anchors.extend(CUSTOM_ID_RE.findall(line))
if not in_para:
- Files reviewed: 11/11 changed files
- Comments generated: 2
- Review effort level: Lite
Inputs are resolved against the repository root before the language check, so a ./ prefix is accepted and a path that escapes the language trees is rejected. The input no longer has to exist: a deleted translation is reported as a missing twin, and a misspelled English path as a missing English page, rather than stopping the run before the comparison.
There was a problem hiding this comment.
🟡 Changes recommended
The parity checker has multiple moderate gaps that can miss or misclassify documented cross-language drift.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (8)
Previously missed (1) — in code that hasn't changed since the last review.
tools/check-parity.py:99
- For a multiline component,
TAG_ID_REis applied only to the opening line. A validid="..."on a later attribute line of<Tab>or<Accordion>is therefore missed, even though this repository uses multiline component attributes (for example,en/develop-plugin/getting-started/getting-started-dify-plugin.mdx:40). A stable anchor added in one language would pass the anchor comparison; parse the complete tag span before extracting IDs.
tools/check-parity.py:99
- This branch treats a component opening line as a leaf and never parses its body. Existing pages use one-line blocks such as
<Info>text</Info>and<Note>text</Note>, so adding or removing a paragraph inside an existing component in zh/ja leaves the component sequence and all counters unchanged and can pass this check. Parse inline component bodies (or use an MDX parser) before comparing block counts.
if COMPONENT_RE.match(line):
cur.components.extend(INLINE_TAG_RE.findall(line))
cur.anchors.extend(TAG_ID_RE.findall(line))
in_para = False
tools/check-parity.py:93
- HTML lists are not counted here. The corpus uses
<ul><li>lists inside table cells (for example,en/self-host/use-dify/nodes/human-input.mdx), but this branch only incrementstable rowsand extracts uppercase component names, so adding or removing a<li>does not change any compared value. Count HTML list items (including their nesting) or parse the table cell structure.
if TABLE_ROW_RE.match(line):
if not TABLE_SEP_RE.match(line):
cur.counts["table rows"] += 1
cur.components.extend(INLINE_TAG_RE.findall(line))
cur.anchors.extend(TAG_ID_RE.findall(line))
tools/check-parity.py:197
- For an absolute page path outside
REPO,Path(raw).resolve().relative_to(REPO)raisesValueErrorbefore the normal validation below, producing an uncaught traceback instead of the documented usage error. Handle paths outside the repository and return the same exit-2 diagnostic used for other invalid page arguments.
given = Path(raw)
tools/check-parity.py:104
- Any line whose first non-whitespace character is
<is discarded as non-content. That includes real one-line HTML paragraphs such as<p>aaPanel 7.0.11 or later</p>(present in all three language copies), so adding or removing such a paragraph can leave both the paragraph count and component sequence unchanged. Parse block HTML content instead of treating every tag-starting line as empty.
cur.components.extend(INLINE_TAG_RE.findall(line))
if line.lstrip().startswith("<"):
cur.anchors.extend(TAG_ID_RE.findall(line))
in_para = False
tools/check-parity.py:55
- Only aggregate counters are retained for paragraphs, lists, code blocks, and table rows. A twin can therefore reorder a paragraph and a list (or a code block and a paragraph) while all counters, component names, and anchors remain equal, producing no issue even though the section's rendered block structure differs. Retain and compare an ordered block-kind sequence per section instead of only totals.
class Section:
def __init__(self, level: int) -> None:
self.level = level
self.counts: Counter = Counter()
self.components: list[str] = []
tools/check-parity.py:199
- The format-check procedure passes changed documentation paths to this command, and
git diff --name-onlyincludes deleted or renamed pages. Requiring the input path itself to exist makes a deleted zh/ja page return usage error 2 before it can be reported as a missing twin (and deleting all three pages also aborts). Accept deleted inputs and handle current/base existence explicitly, or skip deletions, so valid page deletions do not break the parity gate.
try:
tools/check-parity.py:146
- Sorting the IDs makes this comparison set-like and misses a real divergence: if two
<Tab>elements in one section swap theiridvalues, the sorted lists remain equal even though links such as#swagger-apinow open the wrong tab. Compare the ordered IDs or associate each ID with its component so the target mapping is checked.
if sorted(a.anchors) != sorted(b.anchors):
- Files reviewed: 11/11 changed files
- Comments generated: 1
- Review effort level: Lite
The editor test runs after translation, and a correction it produces can change the English. The page now returns to S6 before the translation test, so the zh and ja pages are judged as they will ship. The research skill presents its record under that name.
There was a problem hiding this comment.
🔵 Needs a closer look
The parity checker has unresolved correctness and coverage gaps in HTML/JSX, anchors, section alignment, and encoding.
Review details
Suppressed comments (8)
tools/check-parity.py:107
- This branch drops all content on lines that start with
<instead of parsing text-bearing HTML/JSX. The corpus contains inline paragraphs such asen/self-host/deploy/platform-guides/bt-panel.mdx:25and custom pages whose headings are<h1>/<h2>, so adding or removing one of these blocks in only zh/ja produces no parity signal. Parse these elements (including HTML headings), or explicitly exclude custom/frame pages from this check; otherwise the advertised paragraph/heading coverage is incomplete.
cur.components.extend(INLINE_TAG_RE.findall(line))
if line.lstrip().startswith("<"):
cur.anchors.extend(TAG_ID_RE.findall(line))
in_para = False
tools/check-parity.py:264
- The base-diff filter compares the fully rendered mismatch strings, but those strings include the current English heading text. If an existing drifted section is merely renamed in this PR,
beforeis labeled with the old heading andnowwith the new heading, so theCounterdoes not subtract it and reports unchanged drift as a new issue. Compare stable section identities before formatting the message (for example, an explicit anchor or a revision-stable section key) to preserve the pre-existing-mismatch contract.
seen = Counter(before)
for line in now:
if seen[line]:
seen[line] -= 1
tools/check-parity.py:181
read_workingdecodes pages as UTF-8, but thisgit showcall leavessubprocess.run(text=True)on the host locale. In a non-UTF-8 locale, reading a CJK twin at the base ref can raiseUnicodeDecodeError(or decode to mojibake), so the format check fails before reporting parity. Passencoding="utf-8"here to match the page-file reads.
["git", "-C", str(REPO), "show", f"{ref}:{rel}"], capture_output=True, text=True
tools/check-parity.py:48
- Only opening component tags are recorded:
INLINE_TAG_REcannot match</...>, and the comparison never validates nesting. Removing a closing</Tab>(or replacing it with the wrong closing component) therefore leaves the opening sequence and all counters unchanged, so a broken twin can still returnPARITY OK. Include closing/self-closing component tokens or balance the component tree before comparing.
INLINE_TAG_RE = re.compile(r"<([A-Z][A-Za-z]*)\b")
tools/check-parity.py:107
- Anchor extraction is conditional on the line being a table row, a component opener, or starting with
<. An explicit anchor embedded in normal prose, such astext <a id="stable"></a>, is counted only as a paragraph and its ID is never added; a translation can omit or change it without a mismatch. ExtractTAG_ID_REmatches from every non-fenced line (and handle multiline tags) before these branches.
cur.components.extend(INLINE_TAG_RE.findall(line))
if line.lstrip().startswith("<"):
cur.anchors.extend(TAG_ID_RE.findall(line))
in_para = False
tools/check-parity.py:155
- When a twin inserts or omits a heading, this index-based
zippairs every later twin section with a different English section. The heading-count warning does not prevent the resulting count/component/anchor differences from being emitted as real mismatches, and the base diff can then treat pre-existing drift as newly introduced. Align the section streams before comparing, or stop per-section comparison after the first divergence.
for i, (a, b) in enumerate(zip(en, tw)):
where = names[i]
tools/check-parity.py:102
TAG_ID_REis applied only to the current component line. Existing pages use multiline<Accordion ...>openings (for example,en/develop-plugin/getting-started/getting-started-dify-plugin.mdx:40-44), so anidattribute on a following prop line is ignored; a stable Accordion anchor can diverge without being reported. Accumulate the full opening tag or scan continuation attribute lines before extracting IDs.
if COMPONENT_RE.match(line):
cur.components.extend(INLINE_TAG_RE.findall(line))
cur.anchors.extend(TAG_ID_RE.findall(line))
in_para = False
tools/check-parity.py:169
- Sorting anchor IDs makes this check set-based even though IDs are attached to particular components. Swapping the IDs of two sibling
<Tab>s preserves the sorted list, so links can resolve to the wrong tab while parity passes. Comparea.anchorsandb.anchorsin encounter order.
if sorted(a.anchors) != sorted(b.anchors):
issues.append(
f"{trel}: {where}: anchor ids {','.join(sorted(b.anchors)) or 'none'}, "
f"en {','.join(sorted(a.anchors)) or 'none'}"
- Files reviewed: 11/11 changed files
- Comments generated: 0 new
- Review effort level: Lite
The translation test's reply names the missing English unit as well as a quoted sentence. A round that rewrote several pages as one topic can send them to one reader in navigation order. The sibling-translation match is on shared content, with the audience block an allowed difference. Any English correction accepted in the check stage returns the page to S6 and reruns the translation test. The terminology check counts as clean when its only output is the glossary's dead-row report. Background facts are named as the third exclusion. The research skill's closing rule defers to the pipeline's no-reviewer rule. The style guide's switch rule says the current state, not the default, is visible.
…able cells, HTML blocks, and block order Anchor ids are read from every line outside code fences and from the continuation lines of a tag opened earlier, and compared in encounter order, so a swapped pair of tab ids is reported. Blockquoted list items count as items. Table cells are counted as well as rows. Lowercase HTML blocks such as video and h2 join the component sequence. When counts and components agree but the blocks come in a different order, that is reported. Section labels strip whole tags. Base-ref files are decoded as UTF-8, and an orphan translation already orphaned at the base is pre-existing.
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved parity-checker and translation-review issues need to be addressed.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (4)
.claude/skills/dify-docs-translation-test/SKILL.md:44
- The prompt tells the judge to mark any translated sentence that says something the English does not, but every non-custom zh/ja page intentionally has the extra translation disclaimer required by the language guide. Without an explicit exemption, a fresh judge can report this required boilerplate as a
meaningfailure on every page and distort the verdict. Tell the judge to ignore the standard disclaimer before applying the meaning rule.
Mark every sentence that falls short, quoting it, with one tag each. A
sentence, block (a list, table, code block, callout, or image), or section
of the English page with no counterpart in the translation is marked too,
quoting the English:
tools/check-parity.py:285
- Base-drift suppression compares the rendered issue strings, and those strings include the English section label produced by
labels_for. If this round renames an English heading, an unchanged pre-existing mismatch changes fromsection "Old"tosection "New"and is counted innew, so the parity check fails on drift that the base already had. Compare using a stable section identity independent of display heading text, keeping the English label only for reporting.
seen = Counter(before)
for line in now:
if seen[line]:
seen[line] -= 1
old.append(line)
else:
new.append(line)
tools/check-parity.py:126
LIST_REpreserves only the fact that a line is a list item;blocksrecords every item asitemand drops indentation (and ordered versus unordered markers). A translation can flatten a nested list while keeping the same item count, so the checker reports parity even though the rendered hierarchy changes; the formatting guide requires two-space nesting. Include nesting depth in the per-item structural signature.
if LIST_RE.match(line):
cur.counts["list items"] += 1
cur.blocks.append("item")
in_para = False
tools/check-parity.py:127
- This is line-based rather than Markdown-block-based: a wrapped list item such as
- first linefollowed by an indented continuation does not matchLIST_RE, so lines 129–132 add aparablock for the continuation. If English wraps an item differently from zh/ja (which is common after translation), equivalent list structure is reported as a paragraph mismatch. Consume indented continuation lines as part of the current list item, or parse the Markdown structure before comparing.
if LIST_RE.match(line):
cur.counts["list items"] += 1
cur.blocks.append("item")
in_para = False
continue
- Files reviewed: 11/11 changed files
- Comments generated: 2
- Review effort level: Lite
S7 said the terminology check is clean when only the dead-row report remains, and no such section existed. The check's report gains a Dead glossary rows block, and S7 names it.
… list depth HTML table rows and cells count with the Markdown ones. HTML headings start sections. When a tag opened on an earlier line closes, the rest of that line is scanned for components. List items carry their nesting depth and whether they are ordered, so a flattened or re-marked list reports.
Lists written as HTML inside table cells were invisible, so a dropped item passed. An li tag anywhere outside a code fence counts as a list item.
There was a problem hiding this comment.
🟡 Changes recommended
The parity checker has five unresolved moderate findings affecting baseline matching and equivalent Markdown/MDX handling.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (4)
Previously missed (1) — in code that hasn't changed since the last review.
tools/check-parity.py:155
- An indented continuation of a Markdown list item is treated as a new paragraph here:
- label\n descriptionproduces one list item plus one paragraph, while- label descriptionproduces only the list item. A translation can reflow the same list item this way, so the checker reports a parity mismatch for equivalent structure. Keep continuation lines associated with the list item and only count a nested paragraph when the Markdown actually separates it.
tools/check-parity.py:194
- With
--base, this comparison is positional, butwherecomes from the current English section list. If a change inserts or removes a heading before an existing drift, the unchanged mismatch is emitted under a different section label (and may be compared against a different twin section), soCounter(before)cannot subtract it and pre-existing drift is reported as new. Align sections or normalize mismatch identities before subtracting the base results.
for i, (a, b) in enumerate(zip(en, tw)):
where = names[i]
tools/check-parity.py:93
- When a multiline JSX opening tag closes with another component on the same line (for example,
><Tabs>), this branch adds the nested name tocomponentsbut never records its block. If the translated page puts<Tabs>on its own line, the component and count data are identical butblocksdiffer, so equivalent MDX formatting is reported as parity drift. Parse the remainder through the normal component path or make block comparison insensitive to this wrapping.
cur.anchors.extend(TAG_ID_RE.findall(line))
if ">" in line:
in_tag = False
cur.components.extend(INLINE_TAG_RE.findall(line.split(">", 1)[1]))
continue
tools/check-parity.py:303
- With
--base,beforeandnoware compared as rendered strings, but each string embedswherefromlabels_for()using the English heading text. Renaming an English heading while leaving an existing zh/ja mismatch unchanged changes that label between refs, so theCounterdoes not match it and reports it as a new issue. Compare structured mismatch keys (or resolve section identity independently of heading text) before formatting the message.
before = issues_for(en, lambda rel: read_at(args.base, rel)) or []
seen = Counter(before)
for line in now:
- Files reviewed: 11/11 changed files
- Comments generated: 1
- Review effort level: Lite
The generic id scan ran before the heading branch, so an id on an HTML heading landed on the section above it and a divergence there was reported in the wrong place or missed. Heading ids, HTML or Markdown, now belong to the section the heading opens.
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical and moderate findings affect parity correctness, performance, and review guidance.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (5)
Previously missed (1) — in code that hasn't changed since the last review.
tools/check-parity.py:150
- Deriving the nesting marker with
indent // 2makes valid translated list indentation compare as different levels. The English metadata page uses three spaces for a nested marker while the Japanese twin uses four, although both represent the same nesting; this producesitem@1versusitem@2and a false block-order mismatch. Normalize the indentation to a stable nesting level before comparing it.
.claude/skills/dify-docs-editor-test/SKILL.md:36
- When
{SHAPE}is a file path, as the procedure specifies forreferences/spec-conventions.md, there is no named section to read, but this prompt still says to "read only that section." A judge can therefore skip the file's conventions and misclassify required structure. Distinguish the two cases by saying to read the file, or only the named section when one is provided.
read only that section. A structure the conventions require is not a
tools/check-parity.py:226
- With
--all --base, each page causes three separategit showsubprocesses for the English file and its two twins. On the 291-page corpus described in the PR, one full audit therefore starts about 873 Git processes, making the advertised corpus mode unnecessarily slow. Load the base tree or object contents once and serve path reads from an in-memory cache (or a batch Git reader).
def read_at(ref: str, rel: str) -> str | None:
proc = subprocess.run(
["git", "-C", str(REPO), "show", f"{ref}:{rel}"], capture_output=True, text=True, encoding="utf-8"
)
return proc.stdout if proc.returncode == 0 else None
tools/check-parity.py:48
- This regex records an HTML
<table>wrapper as a component, while Markdown tables are normalized only as rows and cells. For example,en/cloud/use-dify/knowledge/metadata.mdxuses a Markdown table while itsjatwin uses an equivalent<table>with the same rows and cells, so this reports a spuriouscomponents table, en nonemismatch and can block otherwise equivalent new pages. Normalize both representations as a table block (or stop treating the wrapper as a component).
HTML_BLOCK_RE = re.compile(r"^\s*<(video|img|iframe|p|div|table|ul|ol|details|summary)\b")
tools/check-parity.py:308
- Base filtering compares complete diagnostic strings, including the section label derived from the current English heading. If an English heading is renamed—while heading text is intentionally ignored for parity—an existing mismatch changes from
section "Old"tosection "New", soCounter(before)cannot subtract it and the checker reports it as newly introduced. Compare a stable issue key (path, section identity/occurrence, and mismatch kind) and format the label only after subtraction.
seen = Counter(before)
for line in now:
if seen[line]:
seen[line] -= 1
old.append(line)
- Files reviewed: 11/11 changed files
- Comments generated: 1
- Review effort level: Lite
A mistyped --repo made every page read as deleted and the run printed PARITY OK. The path must hold an en/ directory, or the run exits 2.
There was a problem hiding this comment.
🔵 Needs a closer look
Unresolved parity-checker correctness issues need to be addressed before approval.
Review details
Suppressed comments (7)
.claude/skills/dify-docs-write/SKILL.md:82
- The documented exception is undefined:
dify-docs-terminology-checkreportsGeneral Terms,UI Labels,Stale Screenshots, andGlossary Gaps, but has noDead glossary rowssection or classification. As written, an agent cannot determine which findings may remain and may incorrectly treat real terminology findings as clean; either define that report category in the terminology skill or remove this exception.
2. Run `dify-docs-format-check`, then `dify-docs-terminology-check`, then the pack's S7 verifiers, fixing and re-running until each is clean; the terminology check is clean when its only remaining findings sit under its report's Dead glossary rows. A clean run is not a finished page.
tools/check-parity.py:313
- The base subtraction keys an issue by its fully rendered text, including the current English section label from
labels_for(). If a PR renames or inserts a heading while leaving an existing drift unchanged, the base issue has a different label/index andseencannot match it, so the unchanged drift is falsely reported as new despite the documented--basebehavior. Compare a heading-stable issue key (for example, section index plus mismatch kind) before formatting the message.
seen = Counter(before)
for line in now:
if seen[line]:
seen[line] -= 1
old.append(line)
else:
new.append(line)
tools/check-parity.py:123
- This only counts HTML list items as an aggregate and does not preserve list kind or nesting. In particular, list markup inside a table row is handled before the component branch, so changing
<ul>to<ol>or changing nested items into siblings can leave the counts, components, and block sequence identical and pass parity even though the rendered structure changed. Record the HTML list structure/depth as the Markdown list branch does.
cur.counts["list items"] += len(HTML_ITEM_RE.findall(line))
if TABLE_ROW_RE.match(line):
if not TABLE_SEP_RE.match(line):
cur.counts["table rows"] += 1
cur.counts["table cells"] += len(line.strip().strip("|").split("|"))
cur.blocks.append("row")
cur.components.extend(INLINE_TAG_RE.findall(line))
in_para = False
tools/check-parity.py:98
- The prose says only the top-of-page disclaimer is skipped, but this condition drops any line anywhere in a page containing the hard-coded phrase. A legitimate later paragraph that quotes or explains the disclaimer would therefore be omitted from the section counts and could hide a real zh/ja-only addition. Restrict the exemption to the initial disclaimer block (and its expected blockquote form) rather than matching the phrase globally.
if any(m in line for m in DISCLAIMER) or COMMENT_RE.match(line):
continue
tools/check-parity.py:54
INLINE_TAG_REscans raw Markdown, so it also treats angle-bracket placeholders inside inline code as components. For example,en/cli/reference/output-formats-and-exit-codes.mdx:48containsrequest: <METHOD> <url>, which adds a fakeMETHODcomponent even though no MDX tag is rendered. A translated code example that uses a different placeholder then produces a false parity error; strip code spans before extracting tags or tokenize actual MDX/HTML tags.
INLINE_TAG_RE = re.compile(r"<([A-Z][A-Za-z]*)\b")
tools/check-parity.py:157
in_parais reset toFalsefor every list item, so an indented continuation line without a blank line is counted as a new paragraph. In Markdown, a hard-wrapped continuation such as- textfollowed bymoreremains part of the same list-item paragraph; equivalent line wrapping between English and a translation will therefore be reported as parity drift. Track list-item paragraph state (or parse the Markdown AST) rather than treating every post-list indented line as a new paragraph.
cur.anchors.extend(CUSTOM_ID_RE.findall(line))
if not in_para:
cur.counts["paragraphs"] += 1
cur.blocks.append("para")
in_para = True
tools/check-parity.py:81
- This toggles on any line beginning with three backticks/tildes, regardless of the opening fence length. A valid four-backtick wrapper around a nested three-backtick example (the pattern used in
writing-guides/formatting-guide.md) will therefore close the outer block at the inner fence, count its contents as page structure, and reopen at the inner closing fence. Track the fence character and length and only close on a matching fence of sufficient length.
if FENCE_RE.match(line):
if not in_fence:
cur.counts["code blocks"] += 1
cur.blocks.append("code")
in_fence = not in_fence
- Files reviewed: 11/11 changed files
- Comments generated: 0 new
- Review effort level: Lite
…ity-check # Conflicts: # writing-guides/index.md
There was a problem hiding this comment.
🟡 Changes recommended
Multiple moderate correctness and portability issues remain in the parity checker.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (7)
tools/check-parity.py:145
- Lines beginning with an unrecognized lowercase HTML tag are discarded here. For example, a translation-only paragraph rendered as
<span>...</span>or<a href=...>...</a>contributes neither a paragraph nor a component (INLINE_TAG_REonly matches uppercase tags and the block allowlist omits these tags), so the parity check can pass despite extra content. Parse inline/raw HTML as paragraph content, or explicitly classify these tags without dropping their text.
if line.lstrip().startswith("<"):
in_para = False
continue
tools/check-parity.py:173
- The
--basesubtraction uses the rendered mismatch strings asCounterkeys, but those strings include this title. Renaming an English heading (which the checker intentionally does not compare) changessection "Old"tosection "New"and turns an unchanged pre-existing mismatch into a new failure; inserting a duplicate heading shifts the counters similarly. Keep a stable section/mismatch key separate from the display label when comparing refs.
title = re.sub(r"\s+", " ", CUSTOM_ID_RE.sub("", re.sub(r"<[^>]*>", "", sec.title))).strip()[:60]
seen[title] += 1
out.append(f'section "{title}"' + (f" ({seen[title]})" if seen[title] > 1 else ""))
tools/check-parity.py:245
relcomes fromstr(Path.relative_to(...)), which uses backslashes on Windows, but this function only splits on/. On Windows every page is rejected in positional mode and--allfinds zero pages, so the checker cannot run. Normalize the path withPath.parts(or otherwise accept both separators) before mapping the language root.
def to_en(rel: str) -> str | None:
parts = rel.split("/", 1)
if len(parts) != 2 or parts[0] not in LANGS:
return None
return f"en/{parts[1]}"
tools/check-parity.py:54
- The component signature records only opening tags:
INLINE_TAG_REexcludes</...>and the parser never appends closing tags. Consequently a translation with a missing or extra</Tab>/</Info>can have an identical component sequence and pass even though the MDX nesting/rendering is broken. Include closing/self-closing tags in the structural signature or validate tag balance.
INLINE_TAG_RE = re.compile(r"<([A-Z][A-Za-z]*)\b")
tools/check-parity.py:120
- Only the aggregate number of cells is recorded for Markdown tables, not the cell count for each row. For example, English rows shaped 3+3 and a translation shaped 2+4 both produce two
rowblocks and six cells, so a column merge/split can passPARITY OKeven though the table structure no longer mirrors the source. Preserve and compare the per-row cell-count sequence.
cur.counts["table cells"] += len(line.strip().strip("|").split("|"))
tools/check-parity.py:127
- The raw-HTML table path has the same aggregate-cell blind spot: rows with different per-row
<td>/<th>counts can have identical total rows and total cells and therefore pass parity. Record each<tr>'s cell shape (rather than only incrementing section totals) so merged or split cells are detected.
cur.counts["table rows"] += len(HTML_ROW_RE.findall(line))
cur.counts["table cells"] += len(HTML_CELL_RE.findall(line))
tools/check-parity.py:122
- Because the Markdown-table branch returns before
HTML_BLOCK_REis evaluated,HTML_BLOCK_REtags inside table cells are never included incomponents;INLINE_TAG_REonly captures capitalized JSX names. The repository uses tags such as<div>in Markdown table cells, so adding or removing one in a translation while keeping row/cell counts unchanged is invisible to this check. Extract the supported HTML block tags from table rows as well.
cur.components.extend(INLINE_TAG_RE.findall(line))
- Files reviewed: 3/3 changed files
- Comments generated: 1
- Review effort level: Lite
| LIST_MARK_RE = re.compile(r"^(\s*)(?:>\s*)*([-*+]|\d+[.)])\s+") | ||
| INLINE_TAG_RE = re.compile(r"<([A-Z][A-Za-z]*)\b") | ||
| COMMENT_RE = re.compile(r"^\s*\{/\*.*\*/\}\s*$") | ||
| FENCE_RE = re.compile(r"^\s*(```|~~~)") |
A paragraph present in the zh and ja pages and absent from the English one passed every linter, the link check, and two merged commits before an external reviewer caught it. An anchor divergence in the same release passed hunk-count parity. No check looked across languages.
tools/check-parity.pysplits each English page at its headings and compares every section with its zh and ja twins: heading count and level, then paragraphs, list items, code blocks, table rows, components, tabs, and anchor ids. Heading text is translated and not compared; the translation disclaimer is skipped. It ends withPARITY OKor one line per mismatch and exit 1.The format-check skill runs it after the two linters for the English files in scope. On the members page as it stood before the 1.17.1 retranslation it reports 22 mismatches; the shipped page passes. Across the corpus today it reports 407 mismatches on 141 of 291 pages, so a mismatch outside the sections a round touched is reported as pre-existing rather than fixed in passing.
Refs DC-298.