"""Run the repeatable static gates for one completed CM108G unit."""
from __future__ import annotations
from html import unescape
from html.parser import HTMLParser
from pathlib import Path
from xml.etree import ElementTree
import json
import re
import sys
ROOT = Path(__file__).resolve().parents[1]
SVG_BY_WEEK = {1:"evidence-inference-decision.svg",2:"managerial-role-map.svg",3:"decision-memo-flow.svg",4:"week-04-concept-map.svg",5:"goals-planning-architecture.svg",6:"strategy-diversification-architecture.svg"}
class P(HTMLParser):
def __init__(self):
super().__init__()
self.ids = {}
self.frags = []
self.headings = []
self.heading_texts = []
self.fonts = []
self.actions = []
self.main = False
self.tools = False
self.skip_link = False
self.concept_scroll_region = False
self.notes_modal = False
self.table_wrap_count = 0
self.accessible_table_wrap_count = 0
self.external_links = []
self.anchor_depth = 0
self.nested_anchor_count = 0
self._anchor_href = None
self._anchor_parts = []
self._heading_level = None
self._heading_parts = []
def handle_starttag(self, tag, attrs):
a = {k.lower(): v or "" for k, v in attrs}
classes = set(a.get("class", "").split())
if tag == "a":
if self.anchor_depth:
self.nested_anchor_count += 1
self.anchor_depth += 1
if "id" in a:
self.ids[a["id"]] = self.ids.get(a["id"], 0) + 1
if tag == "a" and a.get("href", "").startswith("#"):
self.frags.append(a["href"][1:])
if tag == "a" and a.get("href", "") and not a.get("href", "").startswith("#"):
self._anchor_href = a["href"]
self._anchor_parts = []
if tag == "a" and "skip-link" in classes and a.get("href") == "#reading":
self.skip_link = True
if tag in {"h1", "h2", "h3", "h4", "h5", "h6"}:
self._heading_level = int(tag[1])
self._heading_parts = []
self.headings.append(self._heading_level)
if tag == "button" and a.get("data-font-size"):
self.fonts.append(a["data-font-size"])
if a.get("data-study-action"):
self.actions.append(a["data-study-action"])
if "concept-figure__graphic" in classes:
self.concept_scroll_region = (
a.get("tabindex") == "0" and a.get("role") == "region" and bool(a.get("aria-label"))
)
if "table-wrap" in classes:
self.table_wrap_count += 1
if a.get("tabindex") == "0" and a.get("role") == "region" and bool(a.get("aria-label")):
self.accessible_table_wrap_count += 1
if a.get("id") == "notes-panel":
self.notes_modal = a.get("role") == "dialog" and a.get("aria-modal") == "true"
self.main |= a.get("data-count") == "main-body"
self.tools |= "data-study-tools" in a
def handle_data(self, data):
if self._heading_level is not None:
self._heading_parts.append(data)
if self._anchor_href is not None:
self._anchor_parts.append(data)
def handle_endtag(self, tag):
if tag == "a":
self.anchor_depth = max(0, self.anchor_depth - 1)
if tag == "a" and self._anchor_href is not None:
self.external_links.append((self._anchor_href, " ".join("".join(self._anchor_parts).split())))
self._anchor_href = None
self._anchor_parts = []
if self._heading_level is not None and tag == f"h{self._heading_level}":
self.heading_texts.append((self._heading_level, " ".join("".join(self._heading_parts).split())))
self._heading_level = None
self._heading_parts = []
def raw_markdown_words(text):
"""Legacy audit count; Markdown destinations may inflate this number."""
return len(re.findall(r"\b[A-Za-z]+(?:['’][A-Za-z]+)?\b", text))
def visible_markdown(text):
"""Return learner-visible Markdown text without URLs, tags, or comments."""
text = re.sub(r"<!--.*?-->", " ", text, flags=re.S)
text = re.sub(r"!?\[([^\]]*)\]\((?:[^()]|\([^()]*\))*\)", r"\1", text)
text = re.sub(r"<https?://[^>]+>", " ", text)
text = re.sub(r"https?://[^\s)\]>]+", " ", text)
text = re.sub(r"<[^>]+>", " ", text)
return text
def visible_words(text):
"""Conservative English count: a hyphenated compound is one visible word."""
return len(re.findall(r"\b[A-Za-z]+(?:[-'’][A-Za-z]+)*\b", visible_markdown(text)))
def normalized(text):
return " ".join(unescape(re.sub(r"<[^>]+>", " ", text)).split()).casefold()
def run(week):
unit=f"week-{week:02d}"; module=ROOT/"04_modules"/unit; page=ROOT/"06_web/pages"/f"{unit}.html"; svg_path=ROOT/"06_web/assets/svg"/SVG_BY_WEEK.get(week,f"{unit}-concept-map.svg")
manuscript=(module/"manuscript.md").read_text(encoding="utf-8"); resources=(module/"resources.md").read_text(encoding="utf-8"); glossary=(module/"glossary.md").read_text(encoding="utf-8"); assessment=(module/"assessment.md").read_text(encoding="utf-8"); m=re.search(r"<!-- main-body:start -->(.*?)<!-- main-body:end -->",manuscript,re.S)
if not m: raise ValueError(f"{unit}: main-body markers missing")
body=m.group(1); ptext=page.read_text(encoding="utf-8"); p=P(); p.feed(ptext); css=(ROOT/"06_web/css/textbook.css").read_text(encoding="utf-8"); reader_js=(ROOT/"06_web/js/textbook-reader-v2.js").read_text(encoding="utf-8"); svg=svg_path.read_text(encoding="utf-8"); root=ElementTree.fromstring(svg); a={k.rsplit("}",1)[-1].lower():v for k,v in root.attrib.items()}
forbidden = ("Chief PM", "Research Agent", "Writing Agent", "Reviewer Agent", "Context Packet", "Claim Ledger", "C:\\Users\\", "pm_state.json")
expected = {"toggle-notes", "print", "open-export", "highlight", "underline", "show-note", "download-notes", "download-reading", "clear-record"}
heading_values = [value.casefold() for _, value in p.heading_texts]
svg_labels = []
for node in root.iter():
if node.tag.rsplit("}", 1)[-1] == "text":
value = " ".join("".join(node.itertext()).split())
if value and value not in svg_labels:
svg_labels.append(value)
alternative_match = re.search(r'<div class="concept-figure__alternative"[^>]*>(.*?)</div>', ptext, re.S)
alternative_text = normalized(alternative_match.group(1)) if alternative_match else ""
svg_labels_gate = bool(svg_labels) and all(normalized(label) in alternative_text for label in svg_labels)
if week <= 5:
wrapper = (ROOT / "scripts" / f"build_week{week}_html.py").read_text(encoding="utf-8")
canonical_builder_gate = "def build_legacy" in wrapper and f"return build_unit({week})" in wrapper
else:
canonical_builder_gate = True
controlled_reference_links = (
("https://d21.co.jp/book/detail/978-4-7993-2555-1", "Discover 21 publisher record"),
("https://www.waseda.jp/fcom/wbs/faculty-en/15632", "Waseda Business School author profile"),
)
reference_book_descriptive_link_gate = all(
f"[{label}]({href})" in resources
and any(link_href == href and link_text == label and link_text != link_href for link_href, link_text in p.external_links)
for href, label in controlled_reference_links
)
result = {
"unit": unit,
"word_count_method": "learner-visible English; link labels retained; URL destinations/tags/comments excluded; hyphenated compounds count as one",
"main_body_word_count": visible_words(body),
"raw_markdown_main_body_word_count": raw_markdown_words(body),
"supporting_content_word_count": sum(visible_words(text) for text in (glossary, assessment, resources)),
"raw_markdown_supporting_content_word_count": sum(raw_markdown_words(text) for text in (glossary, assessment, resources)),
"missing_fragments": sorted(set(p.frags) - set(p.ids)),
"duplicate_ids": sorted(key for key, value in p.ids.items() if value > 1),
"word_gate": visible_words(body) >= 5000,
"authoring_buffer_gate": visible_words(body) >= 5100,
"heading_gate": p.headings.count(1) == 1,
"heading_hierarchy_gate": all((later - earlier) <= 1 for earlier, later in zip(p.headings, p.headings[1:])),
"one_learning_map_heading_gate": sum("learning map" in value for value in heading_values) == 1,
"one_no_ai_heading_gate": sum("no ai task" in value for value in heading_values) == 1,
"one_glossary_heading_gate": sum("glossary" in value for value in heading_values) == 1,
"glossary_term_count": len(re.findall(r"(?m)^(?:\d+\.\s+\*\*|##\s+\d+\.)", glossary)),
"glossary_count_gate": 1 <= len(re.findall(r"(?m)^(?:\d+\.\s+\*\*|##\s+\d+\.)", glossary)) <= 15,
"assessment_question_count": len(re.findall(r"(?m)^##\s+(?:Question\s+)?\d+(?=\b|[.:|])", assessment)),
"assessment_count_gate": len(re.findall(r"(?m)^##\s+(?:Question\s+)?\d+(?=\b|[.:|])", assessment)) == 3,
"font_gate": set(p.fonts) >= {"xs", "s", "m", "l", "xl"},
"study_tools": p.tools,
"study_action_gate": expected <= set(p.actions),
"main_body_marker_gate": p.main,
"single_column_css_gate": "column-count: 2" not in css and "column-count: 1" in css,
"reader_v2_gate": "../js/textbook-reader-v2.js" in ptext,
"stable_annotation_gate": all(token in reader_js for token in ("stableHash", "reanchorSegment", "blockId", "start", "end", "quote", "itemMatchesPage")),
"keyboard_selection_gate": all(token in reader_js for token in ("keyboardSelectionPending", "selectionNavigationKeys", "focusToolbar", "selectionTools.contains(document.activeElement)")),
"focus_management_gate": all(token in reader_js for token in ("focusableElements", "lastNotesFocus", "lastDialogFocus", 'setAttribute("aria-modal", "true")')),
"local_date_export_gate": "localDateStamp(new Date())" in reader_js and "toISOString().slice(0, 10)" not in reader_js,
"responsive_contents_gate": "Show page contents" in reader_js and "@media (max-width: 900px)" in css,
"mobile_concept_scroll_gate": ".concept-figure img, .concept-figure svg { width: 760px" in css,
"svg_font_control_gate": "--diagram-text-size" in css and ".concept-figure__graphic svg text" in css,
"skip_link_gate": p.skip_link and ".skip-link:focus" in css,
"concept_scroll_region_gate": p.concept_scroll_region,
"table_wrap_count": p.table_wrap_count,
"accessible_table_wrap_count": p.accessible_table_wrap_count,
"table_accessibility_gate": p.table_wrap_count > 0 and p.accessible_table_wrap_count == p.table_wrap_count,
"notes_modal_gate": p.notes_modal,
"print_notes_gate": "print-notes" in reader_js and ".print-notes { display: block" in css and "break-after: avoid-page" in css,
"privacy_gate": not any(value.casefold() in ptext.casefold() for value in forbidden),
"learner_internal_status_gate": not any(value in ptext.casefold() for value in (
"course edition under review", "this unit is an english course edition under review",
"reviewer-cleared", "instructor-approved", "review candidate",
"pending instructor approval", "approved public-source boundary",
)),
"answer_key_leakage_gate": not any(value in ptext.casefold() for value in (
"answer key", "correct choices", "correct answers", "instructor answer", "scoring guide",
)),
"instructor_only_leakage_gate": not any(value in ptext.casefold() for value in (
"instructor-only:start", "instructor-only:end",
"instructor rationale and scoring guide",
"exclude the following instructor-only block",
)),
"book_url_not_exposed_gate": "docs.google.com/document" not in ptext and "docs.google.com/document" not in resources,
"reference_book_title_gate": "the new business of management" not in (resources + "\n" + ptext).casefold(),
"reference_book_attribution_gate": all(value in resources.casefold() for value in (
"koji mitani", "すべての働く人のための新しい経営学", "the instructor",
"target", "value", "capability", "revenue model", "teacher-provided",
"management theory", "research evidence", "application", "practice",
)) and "inspir" in resources.casefold() and "not a translation" in resources.casefold()
and "official english edition" in resources.casefold() and "substitute" in resources.casefold(),
"reference_book_descriptive_link_gate": reference_book_descriptive_link_gate,
"valid_anchor_nesting_gate": p.nested_anchor_count == 0 and p.anchor_depth == 0,
"inline_citation_gate": len(re.findall(r"https://doi\.org/", body)) >= 3,
"anti_ai_punctuation_gate": "—" not in body and "–" not in body,
"svg_gate": root.tag.endswith("svg") and bool(a.get("viewbox")) and a.get("role") == "img" and bool(a.get("aria-label")),
"svg_text_alternative_gate": bool(alternative_match) and "how the elements connect:" in alternative_text and svg_labels_gate,
"canonical_builder_gate": canonical_builder_gate,
}
required = [
"word_gate", "heading_gate", "heading_hierarchy_gate", "one_learning_map_heading_gate",
"one_no_ai_heading_gate", "one_glossary_heading_gate", "glossary_count_gate", "assessment_count_gate", "font_gate", "study_tools",
"study_action_gate", "main_body_marker_gate", "single_column_css_gate", "reader_v2_gate",
"stable_annotation_gate", "keyboard_selection_gate", "focus_management_gate", "local_date_export_gate",
"responsive_contents_gate", "mobile_concept_scroll_gate", "svg_font_control_gate", "skip_link_gate",
"concept_scroll_region_gate", "table_accessibility_gate", "notes_modal_gate", "print_notes_gate", "privacy_gate", "learner_internal_status_gate", "answer_key_leakage_gate",
"instructor_only_leakage_gate",
"book_url_not_exposed_gate", "reference_book_title_gate", "reference_book_attribution_gate", "reference_book_descriptive_link_gate", "valid_anchor_nesting_gate", "inline_citation_gate", "anti_ai_punctuation_gate", "svg_gate",
"svg_text_alternative_gate", "canonical_builder_gate",
]
result["total_counted_word_count"]=result["main_body_word_count"]+result["supporting_content_word_count"]; result["recommendation"]="pass-static-gates" if all(result[k] for k in required) and not result["missing_fragments"] and not result["duplicate_ids"] else "blocked"
out=ROOT/"07_tests"; out.mkdir(exist_ok=True); (out/f"{unit}-static-qa.json").write_text(json.dumps(result,indent=2),encoding="utf-8",newline="\n"); (out/f"{unit}-static-qa.md").write_text(f"# {unit} static QA\n\n"+"\n".join(f"- **{k}:** {v}" for k,v in result.items())+"\n\nStatic QA does not replace independent Strict Review, browser interaction review, print review, or instructor approval.\n",encoding="utf-8",newline="\n"); print(json.dumps(result,indent=2))
if __name__=="__main__":
if len(sys.argv)!=2 or not sys.argv[1].isdigit(): raise SystemExit("Usage: python scripts/qa_unit.py WEEK")
run(int(sys.argv[1]))
1"""Run the repeatable static gates for one completed CM108G unit."""
2from __future__ import annotations
3from html import unescape
4from html.parser import HTMLParser
5from pathlib import Path
6from xml.etree import ElementTree
7import json
8import re
9import sys
11ROOT = Path(__file__).resolve().parents[1]
12SVG_BY_WEEK = {1:"evidence-inference-decision.svg",2:"managerial-role-map.svg",3:"decision-memo-flow.svg",4:"week-04-concept-map.svg",5:"goals-planning-architecture.svg",6:"strategy-diversification-architecture.svg"}
14class P(HTMLParser):
15 def __init__(self):
16 super().__init__()
17 self.ids = {}
18 self.frags = []
19 self.headings = []
20 self.heading_texts = []
21 self.fonts = []
22 self.actions = []
23 self.main = False
24 self.tools = False
25 self.skip_link = False
26 self.concept_scroll_region = False
27 self.notes_modal = False
28 self.table_wrap_count = 0
29 self.accessible_table_wrap_count = 0
30 self.external_links = []
31 self.anchor_depth = 0
32 self.nested_anchor_count = 0
33 self._anchor_href = None
34 self._anchor_parts = []
35 self._heading_level = None
36 self._heading_parts = []
38 def handle_starttag(self, tag, attrs):
39 a = {k.lower(): v or "" for k, v in attrs}
40 classes = set(a.get("class", "").split())
41 if tag == "a":
42 if self.anchor_depth:
43 self.nested_anchor_count += 1
44 self.anchor_depth += 1
45 if "id" in a:
46 self.ids[a["id"]] = self.ids.get(a["id"], 0) + 1
47 if tag == "a" and a.get("href", "").startswith("#"):
48 self.frags.append(a["href"][1:])
49 if tag == "a" and a.get("href", "") and not a.get("href", "").startswith("#"):
50 self._anchor_href = a["href"]
51 self._anchor_parts = []
52 if tag == "a" and "skip-link" in classes and a.get("href") == "#reading":
53 self.skip_link = True
54 if tag in {"h1", "h2", "h3", "h4", "h5", "h6"}:
55 self._heading_level = int(tag[1])
56 self._heading_parts = []
57 self.headings.append(self._heading_level)
58 if tag == "button" and a.get("data-font-size"):
59 self.fonts.append(a["data-font-size"])
60 if a.get("data-study-action"):
61 self.actions.append(a["data-study-action"])
62 if "concept-figure__graphic" in classes:
63 self.concept_scroll_region = (
64 a.get("tabindex") == "0" and a.get("role") == "region" and bool(a.get("aria-label"))
65 )
66 if "table-wrap" in classes:
67 self.table_wrap_count += 1
68 if a.get("tabindex") == "0" and a.get("role") == "region" and bool(a.get("aria-label")):
69 self.accessible_table_wrap_count += 1
70 if a.get("id") == "notes-panel":
71 self.notes_modal = a.get("role") == "dialog" and a.get("aria-modal") == "true"
72 self.main |= a.get("data-count") == "main-body"
73 self.tools |= "data-study-tools" in a
75 def handle_data(self, data):
76 if self._heading_level is not None:
77 self._heading_parts.append(data)
78 if self._anchor_href is not None:
79 self._anchor_parts.append(data)
81 def handle_endtag(self, tag):
82 if tag == "a":
83 self.anchor_depth = max(0, self.anchor_depth - 1)
84 if tag == "a" and self._anchor_href is not None:
85 self.external_links.append((self._anchor_href, " ".join("".join(self._anchor_parts).split())))
86 self._anchor_href = None
87 self._anchor_parts = []
88 if self._heading_level is not None and tag == f"h{self._heading_level}":
89 self.heading_texts.append((self._heading_level, " ".join("".join(self._heading_parts).split())))
90 self._heading_level = None
91 self._heading_parts = []
93def raw_markdown_words(text):
94 """Legacy audit count; Markdown destinations may inflate this number."""
95 return len(re.findall(r"\b[A-Za-z]+(?:['’][A-Za-z]+)?\b", text))
98def visible_markdown(text):
99 """Return learner-visible Markdown text without URLs, tags, or comments."""
100 text = re.sub(r"<!--.*?-->", " ", text, flags=re.S)
101 text = re.sub(r"!?\[([^\]]*)\]\((?:[^()]|\([^()]*\))*\)", r"\1", text)
102 text = re.sub(r"<https?://[^>]+>", " ", text)
103 text = re.sub(r"https?://[^\s)\]>]+", " ", text)
104 text = re.sub(r"<[^>]+>", " ", text)
105 return text
108def visible_words(text):
109 """Conservative English count: a hyphenated compound is one visible word."""
110 return len(re.findall(r"\b[A-Za-z]+(?:[-'’][A-Za-z]+)*\b", visible_markdown(text)))
113def normalized(text):
114 return " ".join(unescape(re.sub(r"<[^>]+>", " ", text)).split()).casefold()
116def run(week):
117 unit=f"week-{week:02d}"; module=ROOT/"04_modules"/unit; page=ROOT/"06_web/pages"/f"{unit}.html"; svg_path=ROOT/"06_web/assets/svg"/SVG_BY_WEEK.get(week,f"{unit}-concept-map.svg")
118 manuscript=(module/"manuscript.md").read_text(encoding="utf-8"); resources=(module/"resources.md").read_text(encoding="utf-8"); glossary=(module/"glossary.md").read_text(encoding="utf-8"); assessment=(module/"assessment.md").read_text(encoding="utf-8"); m=re.search(r"<!-- main-body:start -->(.*?)<!-- main-body:end -->",manuscript,re.S)
119 if not m: raise ValueError(f"{unit}: main-body markers missing")
120 body=m.group(1); ptext=page.read_text(encoding="utf-8"); p=P(); p.feed(ptext); css=(ROOT/"06_web/css/textbook.css").read_text(encoding="utf-8"); reader_js=(ROOT/"06_web/js/textbook-reader-v2.js").read_text(encoding="utf-8"); svg=svg_path.read_text(encoding="utf-8"); root=ElementTree.fromstring(svg); a={k.rsplit("}",1)[-1].lower():v for k,v in root.attrib.items()}
121 forbidden = ("Chief PM", "Research Agent", "Writing Agent", "Reviewer Agent", "Context Packet", "Claim Ledger", "C:\\Users\\", "pm_state.json")
122 expected = {"toggle-notes", "print", "open-export", "highlight", "underline", "show-note", "download-notes", "download-reading", "clear-record"}
123 heading_values = [value.casefold() for _, value in p.heading_texts]
124 svg_labels = []
125 for node in root.iter():
126 if node.tag.rsplit("}", 1)[-1] == "text":
127 value = " ".join("".join(node.itertext()).split())
128 if value and value not in svg_labels:
129 svg_labels.append(value)
130 alternative_match = re.search(r'<div class="concept-figure__alternative"[^>]*>(.*?)</div>', ptext, re.S)
131 alternative_text = normalized(alternative_match.group(1)) if alternative_match else ""
132 svg_labels_gate = bool(svg_labels) and all(normalized(label) in alternative_text for label in svg_labels)
133 if week <= 5:
134 wrapper = (ROOT / "scripts" / f"build_week{week}_html.py").read_text(encoding="utf-8")
135 canonical_builder_gate = "def build_legacy" in wrapper and f"return build_unit({week})" in wrapper
136 else:
137 canonical_builder_gate = True
138 controlled_reference_links = (
139 ("https://d21.co.jp/book/detail/978-4-7993-2555-1", "Discover 21 publisher record"),
140 ("https://www.waseda.jp/fcom/wbs/faculty-en/15632", "Waseda Business School author profile"),
141 )
142 reference_book_descriptive_link_gate = all(
143 f"[{label}]({href})" in resources
144 and any(link_href == href and link_text == label and link_text != link_href for link_href, link_text in p.external_links)
145 for href, label in controlled_reference_links
146 )
147 result = {
148 "unit": unit,
149 "word_count_method": "learner-visible English; link labels retained; URL destinations/tags/comments excluded; hyphenated compounds count as one",
150 "main_body_word_count": visible_words(body),
151 "raw_markdown_main_body_word_count": raw_markdown_words(body),
152 "supporting_content_word_count": sum(visible_words(text) for text in (glossary, assessment, resources)),
153 "raw_markdown_supporting_content_word_count": sum(raw_markdown_words(text) for text in (glossary, assessment, resources)),
154 "missing_fragments": sorted(set(p.frags) - set(p.ids)),
155 "duplicate_ids": sorted(key for key, value in p.ids.items() if value > 1),
156 "word_gate": visible_words(body) >= 5000,
157 "authoring_buffer_gate": visible_words(body) >= 5100,
158 "heading_gate": p.headings.count(1) == 1,
159 "heading_hierarchy_gate": all((later - earlier) <= 1 for earlier, later in zip(p.headings, p.headings[1:])),
160 "one_learning_map_heading_gate": sum("learning map" in value for value in heading_values) == 1,
161 "one_no_ai_heading_gate": sum("no ai task" in value for value in heading_values) == 1,
162 "one_glossary_heading_gate": sum("glossary" in value for value in heading_values) == 1,
163 "glossary_term_count": len(re.findall(r"(?m)^(?:\d+\.\s+\*\*|##\s+\d+\.)", glossary)),
164 "glossary_count_gate": 1 <= len(re.findall(r"(?m)^(?:\d+\.\s+\*\*|##\s+\d+\.)", glossary)) <= 15,
165 "assessment_question_count": len(re.findall(r"(?m)^##\s+(?:Question\s+)?\d+(?=\b|[.:|])", assessment)),
166 "assessment_count_gate": len(re.findall(r"(?m)^##\s+(?:Question\s+)?\d+(?=\b|[.:|])", assessment)) == 3,
167 "font_gate": set(p.fonts) >= {"xs", "s", "m", "l", "xl"},
168 "study_tools": p.tools,
169 "study_action_gate": expected <= set(p.actions),
170 "main_body_marker_gate": p.main,
171 "single_column_css_gate": "column-count: 2" not in css and "column-count: 1" in css,
172 "reader_v2_gate": "../js/textbook-reader-v2.js" in ptext,
173 "stable_annotation_gate": all(token in reader_js for token in ("stableHash", "reanchorSegment", "blockId", "start", "end", "quote", "itemMatchesPage")),
174 "keyboard_selection_gate": all(token in reader_js for token in ("keyboardSelectionPending", "selectionNavigationKeys", "focusToolbar", "selectionTools.contains(document.activeElement)")),
175 "focus_management_gate": all(token in reader_js for token in ("focusableElements", "lastNotesFocus", "lastDialogFocus", 'setAttribute("aria-modal", "true")')),
176 "local_date_export_gate": "localDateStamp(new Date())" in reader_js and "toISOString().slice(0, 10)" not in reader_js,
177 "responsive_contents_gate": "Show page contents" in reader_js and "@media (max-width: 900px)" in css,
178 "mobile_concept_scroll_gate": ".concept-figure img, .concept-figure svg { width: 760px" in css,
179 "svg_font_control_gate": "--diagram-text-size" in css and ".concept-figure__graphic svg text" in css,
180 "skip_link_gate": p.skip_link and ".skip-link:focus" in css,
181 "concept_scroll_region_gate": p.concept_scroll_region,
182 "table_wrap_count": p.table_wrap_count,
183 "accessible_table_wrap_count": p.accessible_table_wrap_count,
184 "table_accessibility_gate": p.table_wrap_count > 0 and p.accessible_table_wrap_count == p.table_wrap_count,
185 "notes_modal_gate": p.notes_modal,
186 "print_notes_gate": "print-notes" in reader_js and ".print-notes { display: block" in css and "break-after: avoid-page" in css,
187 "privacy_gate": not any(value.casefold() in ptext.casefold() for value in forbidden),
188 "learner_internal_status_gate": not any(value in ptext.casefold() for value in (
189 "course edition under review", "this unit is an english course edition under review",
190 "reviewer-cleared", "instructor-approved", "review candidate",
191 "pending instructor approval", "approved public-source boundary",
192 )),
193 "answer_key_leakage_gate": not any(value in ptext.casefold() for value in (
194 "answer key", "correct choices", "correct answers", "instructor answer", "scoring guide",
195 )),
196 "instructor_only_leakage_gate": not any(value in ptext.casefold() for value in (
197 "instructor-only:start", "instructor-only:end",
198 "instructor rationale and scoring guide",
199 "exclude the following instructor-only block",
200 )),
201 "book_url_not_exposed_gate": "docs.google.com/document" not in ptext and "docs.google.com/document" not in resources,
202 "reference_book_title_gate": "the new business of management" not in (resources + "\n" + ptext).casefold(),
203 "reference_book_attribution_gate": all(value in resources.casefold() for value in (
204 "koji mitani", "すべての働く人のための新しい経営学", "the instructor",
205 "target", "value", "capability", "revenue model", "teacher-provided",
206 "management theory", "research evidence", "application", "practice",
207 )) and "inspir" in resources.casefold() and "not a translation" in resources.casefold()
208 and "official english edition" in resources.casefold() and "substitute" in resources.casefold(),
209 "reference_book_descriptive_link_gate": reference_book_descriptive_link_gate,
210 "valid_anchor_nesting_gate": p.nested_anchor_count == 0 and p.anchor_depth == 0,
211 "inline_citation_gate": len(re.findall(r"https://doi\.org/", body)) >= 3,
212 "anti_ai_punctuation_gate": "—" not in body and "–" not in body,
213 "svg_gate": root.tag.endswith("svg") and bool(a.get("viewbox")) and a.get("role") == "img" and bool(a.get("aria-label")),
214 "svg_text_alternative_gate": bool(alternative_match) and "how the elements connect:" in alternative_text and svg_labels_gate,
215 "canonical_builder_gate": canonical_builder_gate,
216 }
217 required = [
218 "word_gate", "heading_gate", "heading_hierarchy_gate", "one_learning_map_heading_gate",
219 "one_no_ai_heading_gate", "one_glossary_heading_gate", "glossary_count_gate", "assessment_count_gate", "font_gate", "study_tools",
220 "study_action_gate", "main_body_marker_gate", "single_column_css_gate", "reader_v2_gate",
221 "stable_annotation_gate", "keyboard_selection_gate", "focus_management_gate", "local_date_export_gate",
222 "responsive_contents_gate", "mobile_concept_scroll_gate", "svg_font_control_gate", "skip_link_gate",
223 "concept_scroll_region_gate", "table_accessibility_gate", "notes_modal_gate", "print_notes_gate", "privacy_gate", "learner_internal_status_gate", "answer_key_leakage_gate",
224 "instructor_only_leakage_gate",
225 "book_url_not_exposed_gate", "reference_book_title_gate", "reference_book_attribution_gate", "reference_book_descriptive_link_gate", "valid_anchor_nesting_gate", "inline_citation_gate", "anti_ai_punctuation_gate", "svg_gate",
226 "svg_text_alternative_gate", "canonical_builder_gate",
227 ]
228 result["total_counted_word_count"]=result["main_body_word_count"]+result["supporting_content_word_count"]; result["recommendation"]="pass-static-gates" if all(result[k] for k in required) and not result["missing_fragments"] and not result["duplicate_ids"] else "blocked"
229 out=ROOT/"07_tests"; out.mkdir(exist_ok=True); (out/f"{unit}-static-qa.json").write_text(json.dumps(result,indent=2),encoding="utf-8",newline="\n"); (out/f"{unit}-static-qa.md").write_text(f"# {unit} static QA\n\n"+"\n".join(f"- **{k}:** {v}" for k,v in result.items())+"\n\nStatic QA does not replace independent Strict Review, browser interaction review, print review, or instructor approval.\n",encoding="utf-8",newline="\n"); print(json.dumps(result,indent=2))
231if __name__=="__main__":
232 if len(sys.argv)!=2 or not sys.argv[1].isdigit(): raise SystemExit("Usage: python scripts/qa_unit.py WEEK")
233 run(int(sys.argv[1]))