knowme-careerforge-skill 0.0.2 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/AGENT.md +276 -0
  2. package/ARCHITECTURE.md +216 -0
  3. package/README.md +34 -54
  4. package/README.zh-CN.md +35 -57
  5. package/SKILL.md +28 -137
  6. package/agents/claude/knowme-careerforge.md +2 -16
  7. package/agents/codex/knowme-careerforge.yaml +5 -7
  8. package/agents/cursor/knowme-careerforge.mdc +4 -9
  9. package/agents/opencode/skill.yaml +1 -1
  10. package/agents/windsurf/knowme-careerforge.rules +2 -12
  11. package/dist/cli/src/commands/init.js +9 -0
  12. package/dist/cli/src/index.js +40 -19
  13. package/dist/scripts/rendering/engine-loader.d.ts +2 -0
  14. package/dist/scripts/rendering/engine-loader.js +46 -0
  15. package/dist/scripts/rendering/render-pdf.d.ts +3 -8
  16. package/dist/scripts/rendering/render-pdf.js +7 -103
  17. package/dist/scripts/rendering/runtime.d.ts +9 -0
  18. package/dist/scripts/rendering/runtime.js +63 -0
  19. package/dist/scripts/validation/validate-ats.d.ts +2 -22
  20. package/dist/scripts/validation/validate-ats.js +6 -168
  21. package/dist/scripts/validation/validate-layout.d.ts +2 -23
  22. package/dist/scripts/validation/validate-layout.js +5 -159
  23. package/package.json +16 -4
  24. package/references/01-evidence-mining.md +5 -0
  25. package/references/02-career-goal.md +38 -0
  26. package/references/03-jd-analysis.md +44 -0
  27. package/references/04-template-selection.md +43 -0
  28. package/references/05-html-canvas-tokens.md +12 -0
  29. package/references/06-qa-and-rendering.md +14 -0
  30. package/references/07-artifact-contract.md +64 -0
  31. package/scripts/Agent.md +9 -136
  32. package/scripts/build/build-gallery.py +66 -56
  33. package/scripts/build/run-all-tests.py +1 -0
  34. package/scripts/build/sync-version.py +37 -10
  35. package/scripts/contracts/profile.py +101 -0
  36. package/scripts/evidence/extract-evidence.py +109 -62
  37. package/scripts/pipeline/forge.py +266 -136
  38. package/scripts/rendering/README.md +65 -0
  39. package/scripts/rendering/browser-engine.d.ts +15 -0
  40. package/scripts/rendering/browser-engine.js +366 -0
  41. package/scripts/rendering/browser_engine.py +39 -0
  42. package/scripts/rendering/engine-loader.ts +11 -0
  43. package/scripts/rendering/render-pdf.py +26 -151
  44. package/scripts/rendering/render-pdf.ts +7 -83
  45. package/scripts/template/instantiate-resume.py +266 -167
  46. package/scripts/template/search-template.py +367 -89
  47. package/scripts/validation/measure-dom.js +5 -0
  48. package/scripts/validation/validate-ats.ts +6 -174
  49. package/scripts/validation/validate-layout.ts +5 -159
  50. package/scripts/validation/validate-resume.py +23 -134
  51. package/skill.json +1 -1
  52. package/src/knowledge/index.json +390 -10
  53. package/src/knowledge/resume-schema.json +377 -71
  54. package/src/knowledge/templates.json +388 -8
  55. package/src/references/01-evidence-mining.md +5 -0
  56. package/src/references/02-career-goal.md +38 -0
  57. package/src/references/03-jd-analysis.md +44 -0
  58. package/src/references/04-template-selection.md +43 -0
  59. package/src/references/05-html-canvas-tokens.md +12 -0
  60. package/src/references/06-qa-and-rendering.md +14 -0
  61. package/src/references/07-artifact-contract.md +64 -0
  62. package/src/templates/Agent.md +11 -0
  63. package/src/templates/academic-research/README.md +25 -0
  64. package/src/templates/academic-research/canvas.html +21 -0
  65. package/src/templates/academic-research/metadata.json +38 -0
  66. package/src/templates/academic-research/sample-profile.json +86 -0
  67. package/src/templates/academic-research/style.css +213 -0
  68. package/src/templates/classic/README.md +9 -0
  69. package/src/templates/classic/canvas.html +21 -0
  70. package/src/templates/classic/metadata.json +3 -0
  71. package/src/templates/classic/sample-profile.json +86 -0
  72. package/src/templates/classic/style.css +35 -54
  73. package/src/templates/common/base.css +182 -0
  74. package/src/templates/common/canvas-bindings.css +41 -0
  75. package/src/templates/common/resume-contract.md +8 -0
  76. package/src/templates/compact-dense/README.md +25 -0
  77. package/src/templates/compact-dense/canvas.html +14 -0
  78. package/src/templates/compact-dense/metadata.json +38 -0
  79. package/src/templates/compact-dense/sample-profile.json +86 -0
  80. package/src/templates/compact-dense/style.css +218 -0
  81. package/src/templates/creative-tech/README.md +26 -0
  82. package/src/templates/creative-tech/canvas.html +15 -0
  83. package/src/templates/creative-tech/metadata.json +39 -0
  84. package/src/templates/creative-tech/sample-profile.json +86 -0
  85. package/src/templates/creative-tech/style.css +257 -0
  86. package/src/templates/data-analyst/README.md +26 -0
  87. package/src/templates/data-analyst/canvas.html +15 -0
  88. package/src/templates/data-analyst/metadata.json +39 -0
  89. package/src/templates/data-analyst/sample-profile.json +86 -0
  90. package/src/templates/data-analyst/style.css +285 -0
  91. package/src/templates/executive/README.md +9 -0
  92. package/src/templates/executive/canvas.html +15 -0
  93. package/src/templates/executive/sample-profile.json +86 -0
  94. package/src/templates/executive/style.css +83 -74
  95. package/src/templates/international-flow/README.md +26 -0
  96. package/src/templates/international-flow/canvas.html +21 -0
  97. package/src/templates/international-flow/metadata.json +37 -0
  98. package/src/templates/international-flow/sample-profile.json +86 -0
  99. package/src/templates/international-flow/style.css +208 -0
  100. package/src/templates/minimal/README.md +9 -0
  101. package/src/templates/minimal/canvas.html +21 -0
  102. package/src/templates/minimal/sample-profile.json +86 -0
  103. package/src/templates/minimal/style.css +54 -72
  104. package/src/templates/modern/README.md +9 -0
  105. package/src/templates/modern/canvas.html +15 -0
  106. package/src/templates/modern/sample-profile.json +86 -0
  107. package/src/templates/modern/style.css +78 -71
  108. package/src/templates/startup-generalist/README.md +25 -0
  109. package/src/templates/startup-generalist/canvas.html +21 -0
  110. package/src/templates/startup-generalist/metadata.json +37 -0
  111. package/src/templates/startup-generalist/sample-profile.json +86 -0
  112. package/src/templates/startup-generalist/style.css +237 -0
  113. package/src/references/ats.md +0 -17
  114. package/src/references/evidence-first.md +0 -22
  115. package/src/references/pdf.md +0 -40
  116. package/src/references/resume-writing.md +0 -25
  117. package/src/references/template-design.md +0 -57
  118. package/src/templates/classic/template.html +0 -133
  119. package/src/templates/executive/template.html +0 -101
  120. package/src/templates/minimal/template.html +0 -114
  121. package/src/templates/modern/template.html +0 -139
  122. package/src/workflows/analyze-job.md +0 -16
  123. package/src/workflows/define-goal.md +0 -16
  124. package/src/workflows/forge-resume.md +0 -25
  125. package/src/workflows/know-me.md +0 -21
  126. package/src/workflows/position.md +0 -19
  127. package/src/workflows/review.md +0 -31
@@ -1,142 +1,272 @@
1
1
  #!/usr/bin/env python3
2
- """
3
- KnowMe CareerForge — Unified End-to-End Forge Engine
4
- 一键打通:代码仓事实挖掘 (repo-to-resume) ➔ JD 深度解析 ➔ 多维模板检索 ➔ HTML 工作区装配 ➔ Dual-QA 质检 ➔ 确定性 PDF 导出。
5
- """
6
-
7
- import sys, os, json, argparse, subprocess
2
+ """Run an isolated user-input resume build with explicit artifact and QA states."""
3
+ import argparse
4
+ import contextlib
5
+ import copy
6
+ import hashlib
7
+ import importlib.util
8
+ import io
9
+ import json
10
+ import os
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ import tempfile
15
+ import uuid
16
+ from datetime import datetime, timezone
8
17
  from pathlib import Path
9
18
 
10
- def get_project_root() -> Path:
11
- curr = Path(__file__).resolve().parent
12
- for _ in range(5):
13
- if (curr / "SKILL.md").exists() or (curr / "package.json").exists():
14
- return curr
15
- curr = curr.parent
16
- return Path.cwd()
19
+ ROOT = Path(__file__).resolve().parents[2]
17
20
 
18
- def main():
19
- parser = argparse.ArgumentParser(description="KnowMe CareerForge — One-Shot Unified Forge Engine")
20
- parser.add_argument("--repo", "-r", default=".", help="Target code repository path (default: current directory)")
21
- parser.add_argument("--role", help="Target job title / role (e.g. 'AI Agent Engineer', '资深前端专家')")
22
- parser.add_argument("--jd", help="Path to JD file or raw JD text")
23
- parser.add_argument("--template", "-t", default="minimal", help="Template ID (minimal, modern, executive, classic)")
24
- parser.add_argument("--name", help="Candidate full name override")
25
- parser.add_argument("--email", help="Candidate email override")
26
- parser.add_argument("--phone", help="Candidate phone override")
27
- parser.add_argument("--output", "-o", default="workspace/resume.pdf", help="Output PDF file path")
28
- parser.add_argument("--html-output", default="workspace/resume.html", help="Intermediate working canvas HTML path")
29
- parser.add_argument("--quiet", "-q", action="store_true", help="Quiet mode (compact JSON output for AI Agent)")
30
21
 
31
- args = parser.parse_args()
32
- root = get_project_root()
33
- scripts_dir = root / "scripts"
34
-
35
- if not args.quiet:
36
- print("==============================================================")
37
- print(" KnowMe CareerForge — One-Shot Resume Engineering Pipeline")
38
- print(f" Target Role : {args.role or 'Auto-Detect'}")
39
- print(f" Repo Source : {args.repo}")
40
- print(f" Template : {args.template}")
41
- print("==============================================================")
42
-
43
- # 1. 证据挖掘 (Stage 1: Evidence Mining)
44
- evidence_json = Path("workspace/evidence-master.json")
45
-
46
- extract_script = scripts_dir / "evidence" / "extract-evidence.py"
47
- if not extract_script.exists():
48
- extract_script = scripts_dir / "extract-evidence.py"
49
-
50
- evidence_cmd = [sys.executable, str(extract_script), "--repo", args.repo, "--output", str(evidence_json), "--quiet"]
51
- if args.name: evidence_cmd.extend(["--name", args.name])
52
- if args.role: evidence_cmd.extend(["--role", args.role])
53
- if args.email: evidence_cmd.extend(["--email", args.email])
54
- if args.phone: evidence_cmd.extend(["--phone", args.phone])
55
-
56
- subprocess.run(evidence_cmd, check=True)
57
- if not args.quiet:
58
- print("[✓] Stage 1: Codebase evidence mined -> workspace/evidence-master.json")
59
-
60
- # 2. JD 分析与关键词提取 (Stage 2: JD Analysis)
61
- keywords = []
62
- if args.jd:
63
- try:
64
- analyze_script = scripts_dir / "evidence" / "analyze-jd.py"
65
- if not analyze_script.exists():
66
- analyze_script = scripts_dir / "analyze-jd.py"
67
-
68
- jd_cmd = [sys.executable, str(analyze_script)]
69
- if Path(args.jd).exists():
70
- jd_cmd.extend(["--jd", args.jd])
22
+ def load_module(name, relative):
23
+ spec = importlib.util.spec_from_file_location(name, ROOT / 'scripts' / relative)
24
+ module = importlib.util.module_from_spec(spec)
25
+ spec.loader.exec_module(module)
26
+ return module
27
+
28
+
29
+ contract = load_module('profile_contract', 'contracts/profile.py')
30
+
31
+
32
+ def atomic_write(path, data):
33
+ path = Path(path)
34
+ path.parent.mkdir(parents=True, exist_ok=True)
35
+ fd, temp = tempfile.mkstemp(prefix='.' + path.name + '-', dir=path.parent)
36
+ try:
37
+ with os.fdopen(fd, 'wb') as stream:
38
+ stream.write(data if isinstance(data, bytes) else data.encode('utf-8'))
39
+ os.replace(temp, path)
40
+ finally:
41
+ if os.path.exists(temp):
42
+ os.unlink(temp)
43
+
44
+
45
+ def write_json(path, value):
46
+ atomic_write(path, json.dumps(value, ensure_ascii=False, indent=2) + '\n')
47
+
48
+
49
+ def file_hash(path):
50
+ return hashlib.sha256(Path(path).read_bytes()).hexdigest()
51
+
52
+
53
+ def publish_aliases(pairs):
54
+ """Stage every requested copy before publishing; rollback ordinary write failures.
55
+
56
+ The run directory remains canonical. Lock files reject concurrent publishers
57
+ to the same requested destination instead of interleaving their outputs.
58
+ """
59
+ staged, locks, replaced = [], [], []
60
+ try:
61
+ for source, target in pairs:
62
+ target.parent.mkdir(parents=True, exist_ok=True)
63
+ lock = target.with_name('.' + target.name + '.knowme-lock')
64
+ fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
65
+ os.close(fd)
66
+ locks.append(lock)
67
+ if target.exists() and not target.is_file():
68
+ raise ValueError('Output is not a regular file: ' + str(target))
69
+ backup = target.read_bytes() if target.exists() else None
70
+ fd, temp = tempfile.mkstemp(prefix='.knowme-export-', dir=target.parent)
71
+ os.close(fd)
72
+ staged.append((Path(temp), target, backup))
73
+ shutil.copyfile(source, temp)
74
+ for temp, target, backup in staged:
75
+ os.replace(temp, target)
76
+ replaced.append((target, backup))
77
+ except Exception:
78
+ for target, backup in reversed(replaced):
79
+ if backup is None:
80
+ target.unlink(missing_ok=True)
71
81
  else:
72
- jd_cmd.extend(["--text", args.jd])
73
- jd_res = subprocess.run(jd_cmd, stdout=subprocess.PIPE, text=True, check=True)
74
- for line in jd_res.stdout.splitlines():
75
- if "Must-have Skills" in line or "Detected" in line:
76
- parts = line.split(":")[-1].strip().split(",")
77
- keywords.extend([p.strip() for p in parts if p.strip()])
78
- except Exception:
79
- pass
80
-
81
- # 3. 模板装配到工作区 (Stage 3 & 4: Template Instantiation)
82
- kw_arg = ",".join(keywords[:8]) if keywords else "TypeScript,Playwright,Python,Docker"
83
-
84
- instantiate_script = scripts_dir / "template" / "instantiate-resume.py"
85
- if not instantiate_script.exists():
86
- instantiate_script = scripts_dir / "instantiate-resume.py"
87
-
88
- instantiate_cmd = [
89
- sys.executable, str(instantiate_script),
90
- "--template", args.template,
91
- "--profile", str(evidence_json),
92
- "--keywords", kw_arg,
93
- "--output", args.html_output
94
- ]
95
- subprocess.run(instantiate_cmd, stdout=subprocess.DEVNULL if args.quiet else None, check=True)
96
- if not args.quiet:
97
- print(f"[✓] Stage 2: HTML Working Canvas instantiated -> {args.html_output}")
98
-
99
- # 4. 质检与自愈校验 (Stage 5: Dual QA Validation)
100
- val_script = scripts_dir / "validation" / "validate-resume.py"
101
- if not val_script.exists():
102
- val_script = scripts_dir / "validate-resume.py"
103
-
104
- validator_cmd = [
105
- sys.executable, str(val_script),
106
- args.html_output
107
- ]
108
- subprocess.run(validator_cmd, stdout=subprocess.DEVNULL if args.quiet else None, check=True)
109
- if not args.quiet:
110
- print("[✓] Stage 3: Dual QA Validation Passed 100%")
111
-
112
- # 5. 确定性渲染导出 PDF (Stage 6: PDF Export)
113
- render_script = scripts_dir / "rendering" / "render-pdf.py"
114
- if not render_script.exists():
115
- render_script = scripts_dir / "render-pdf.py"
116
-
117
- render_cmd = [
118
- sys.executable, str(render_script),
119
- args.html_output, args.output
120
- ]
121
- if args.quiet:
122
- render_cmd.append("--quiet")
123
- subprocess.run(render_cmd, check=True)
124
-
125
- if not args.quiet:
126
- print("==============================================================")
127
- print(f" [✓] ALL STAGES COMPLETED SUCCESSFULLY!")
128
- print(f" HTML Working Canvas : {Path(args.html_output).resolve()}")
129
- print(f" Final Verified PDF : {Path(args.output).resolve()}")
130
- print("==============================================================")
131
- else:
132
- result = {
133
- "status": "SUCCESS",
134
- "htmlCanvas": str(Path(args.html_output).resolve()),
135
- "pdfDelivery": str(Path(args.output).resolve()),
136
- "evidenceProfile": str(evidence_json.resolve()),
137
- "templateUsed": args.template
138
- }
139
- print(json.dumps(result, ensure_ascii=False, indent=2))
140
-
141
- if __name__ == "__main__":
142
- main()
82
+ atomic_write(target, backup)
83
+ raise
84
+ finally:
85
+ for temp, _, _ in staged:
86
+ temp.unlink(missing_ok=True)
87
+ for lock in locks:
88
+ lock.unlink(missing_ok=True)
89
+
90
+
91
+ class Parser(argparse.ArgumentParser):
92
+ def error(self, message):
93
+ print(json.dumps({'status': 'FAIL', 'stage': 'arguments', 'errors': [message],
94
+ 'warnings': [], 'checks': {}, 'outputs': {}}, ensure_ascii=False))
95
+ self.exit(1)
96
+
97
+
98
+ def main():
99
+ parser = Parser(description=__doc__)
100
+ parser.add_argument('--profile-json', required=True, help='User/Agent supplied facts or Draft/Master document; never a repository')
101
+ parser.add_argument('--workspace', default='workspace/runs', help='Parent directory for unique run directories')
102
+ parser.add_argument('--draft', action='store_true', help='Create an incomplete draft canvas without claiming PDF delivery')
103
+ parser.add_argument('--role')
104
+ parser.add_argument('--jd', help='Optional JD path or inline text; used only as highlighting/search hints')
105
+ parser.add_argument('--template', '-t')
106
+ parser.add_argument('--font-preset', choices=('system', 'arial-unicode'), default='system', help='Explicit local font choice; missing fonts fail verification')
107
+ for key in ('name', 'email', 'phone'):
108
+ parser.add_argument('--' + key)
109
+ parser.add_argument('--output', '-o', help='Optional verified PDF copy; default is the isolated run PDF')
110
+ parser.add_argument('--html-output', help='Optional verified HTML copy; default is the isolated run canvas')
111
+ parser.add_argument('--expected-pages', type=int, choices=(1, 2), default=1)
112
+ parser.add_argument('--auto-heal', action='store_true')
113
+ parser.add_argument('--quiet', '-q', action='store_true')
114
+ args = parser.parse_args()
115
+ report = {'schemaVersion': '1.0', 'runId': uuid.uuid4().hex, 'status': 'RUNNING',
116
+ 'stage': 'input', 'errors': [], 'warnings': [], 'checks': {}, 'outputs': {}}
117
+ run = None
118
+ exit_code = 1
119
+ try:
120
+ parent = Path(args.workspace).resolve()
121
+ parent.mkdir(parents=True, exist_ok=True)
122
+ run = parent / report['runId']
123
+ run.mkdir(mode=0o700)
124
+ report['runDirectory'] = str(run)
125
+ report['startedAt'] = datetime.now(timezone.utc).isoformat()
126
+ write_json(run / 'manifest.json', report)
127
+ source = Path(args.profile_json).resolve()
128
+ aliases = [Path(p).resolve() for p in (args.html_output, args.output) if p]
129
+ if len(set([source] + aliases)) != len([source] + aliases):
130
+ raise ValueError('Input, HTML and PDF output paths must be distinct')
131
+ for target in aliases:
132
+ if target == run or run in target.parents:
133
+ raise ValueError('Explicit output cannot replace an internal run artifact')
134
+ raw_bytes = source.read_bytes()
135
+ atomic_write(run / 'input.json', raw_bytes)
136
+ supplied = contract.load_document(json.loads(raw_bytes))
137
+ if supplied['kind'] == 'variant':
138
+ raise ValueError('A Variant is not a Master; use its original Master to derive another resume')
139
+ if supplied['kind'] == 'draft' and not args.draft:
140
+ raise ValueError('Draft input cannot be delivered; review facts and provide a Master, or use --draft')
141
+ facts = copy.deepcopy(supplied['profile'])
142
+ for key in ('name', 'email', 'phone'):
143
+ if getattr(args, key) is not None:
144
+ facts['basics'][key] = getattr(args, key)
145
+ kind = 'draft' if args.draft else 'master'
146
+ master = contract.document(kind, facts, {'type': 'user-input', 'path': str(source),
147
+ 'inputSha256': hashlib.sha256(raw_bytes).hexdigest(),
148
+ 'suppliedKind': supplied['kind'], 'suppliedSource': supplied['source']})
149
+ write_json(run / (kind + '.json'), master)
150
+ report['checks']['missingFields'] = master['missingFields']
151
+ report['warnings'].extend('Missing optional contact/position field: ' + v for v in master['missingFields'] if v != 'basics.name')
152
+ if not args.draft:
153
+ errors = contract.delivery_errors(master['profile'])
154
+ if errors:
155
+ raise ValueError('; '.join(errors))
156
+ profile = copy.deepcopy(master['profile'])
157
+ if args.role is not None:
158
+ profile['basics']['title'] = args.role
159
+ keywords = []
160
+ if args.jd:
161
+ report['stage'] = 'jd'
162
+ try:
163
+ jd_file = Path(args.jd)
164
+ jd = jd_file.read_text(encoding='utf-8') if jd_file.is_file() else args.jd
165
+ except OSError:
166
+ jd = args.jd
167
+ atomic_write(run / 'jd.txt', jd)
168
+ analyzer = load_module('jd_analyzer', 'evidence/analyze-jd.py')
169
+ with contextlib.redirect_stdout(io.StringIO()):
170
+ result = analyzer.analyze_jd_text(jd)
171
+ write_json(run / 'jd-analysis.json', result)
172
+ keywords = result.get('detectedSkills', [])
173
+ report['stage'] = 'template'
174
+ template = args.template
175
+ if not template:
176
+ search = load_module('template_search', 'template/search-template.py')
177
+ with contextlib.redirect_stdout(io.StringIO()):
178
+ results = search.search_templates(role=args.role or profile['basics'].get('title') or '',
179
+ keywords=','.join(keywords), target_pages=args.expected_pages, engine='hybrid')
180
+ if not results:
181
+ raise ValueError('No matching template available')
182
+ template = results[0].get('id') or results[0]['template']['id']
183
+ variant = contract.document('draft' if args.draft else 'variant', profile,
184
+ {'masterSha256': master['profileSha256'], 'masterPath': str(run / (kind + '.json')),
185
+ 'template': template, 'targetRole': args.role, 'fontPreset': args.font_preset})
186
+ variant_path = run / ('draft-canvas-profile.json' if args.draft else 'variant.json')
187
+ write_json(variant_path, variant)
188
+ report['templateUsed'] = template
189
+ report['fontPreset'] = args.font_preset
190
+ report['stage'] = 'binding'
191
+ inst = load_module('instantiate_resume', 'template/instantiate-resume.py')
192
+ canvas, pdf = run / 'resume.html', run / 'resume.pdf'
193
+ with contextlib.redirect_stdout(io.StringIO()):
194
+ inst.instantiate_workspace(template, str(variant_path), ','.join(keywords), str(canvas), font_preset=args.font_preset)
195
+ if args.draft:
196
+ report.update(status='DRAFT', stage='draft', outputs={'draft': str(run / 'draft.json'), 'htmlCanvas': str(canvas)})
197
+ exit_code = 0
198
+ else:
199
+ report['stage'] = 'qa'
200
+ cmd = ['node', str(ROOT / 'scripts/rendering/browser-engine.js'), str(canvas),
201
+ '--expected-pages', str(args.expected_pages), '--output', str(pdf)]
202
+ if args.auto_heal:
203
+ cmd.append('--auto-heal')
204
+ process = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
205
+ try:
206
+ qa = json.loads(process.stdout)
207
+ if not isinstance(qa, dict):
208
+ raise ValueError('QA result must be an object')
209
+ expected_exit = {'PASS': 0, 'FAIL': 1, 'UNVERIFIED': 2}.get(qa.get('status'))
210
+ if expected_exit is None or process.returncode != expected_exit:
211
+ raise ValueError('QA status and exit code disagree')
212
+ if (not isinstance(qa.get('checks'), dict)
213
+ or any(not isinstance(v, dict) for v in qa['checks'].values())
214
+ or any(not isinstance(qa.get(key), list) or any(not isinstance(v, str) for v in qa[key])
215
+ for key in ('errors', 'warnings'))):
216
+ raise ValueError('Malformed QA diagnostics')
217
+ except (ValueError, TypeError) as error:
218
+ report['status'] = 'UNVERIFIED'
219
+ raise ValueError('Browser returned no valid QA result: ' + str(error) + '; ' + process.stderr[:400])
220
+ write_json(run / 'qa.json', qa)
221
+ report['checks']['qa'] = qa
222
+ report['warnings'].extend(qa.get('warnings', []))
223
+ if process.returncode != 0 or qa.get('status') != 'PASS':
224
+ report['status'] = 'UNVERIFIED' if qa.get('status') == 'UNVERIFIED' else 'FAIL'
225
+ raise ValueError('; '.join(qa.get('errors', [])) or 'PDF validation did not pass')
226
+ checks = qa.get('checks', {})
227
+ required_checks = ('input', 'fonts', 'dom', 'pdf', 'text', 'output')
228
+ if qa.get('errors') or any(checks.get(key, {}).get('status') != 'PASS' for key in required_checks):
229
+ report['status'] = 'UNVERIFIED'
230
+ raise ValueError('Incomplete or contradictory QA result; refusing delivery')
231
+ accepted = checks['output']
232
+ if not accepted.get('committed') or accepted.get('path') != str(pdf):
233
+ report['status'] = 'UNVERIFIED'
234
+ raise ValueError('QA did not commit this run PDF')
235
+ if not pdf.is_file() or not pdf.read_bytes().startswith(b'%PDF-'):
236
+ raise ValueError('Verified PDF was not produced by this run')
237
+ if accepted.get('pdfSha256') != file_hash(pdf) or accepted.get('htmlSha256') != file_hash(canvas):
238
+ raise ValueError('Output changed after QA or acceptance hashes are missing')
239
+ report['stage'] = 'publish'
240
+ pairs = []
241
+ if args.html_output:
242
+ pairs.append((canvas, Path(args.html_output).resolve()))
243
+ if args.output:
244
+ pairs.append((pdf, Path(args.output).resolve()))
245
+ publish_aliases(pairs)
246
+ report.update(status='PASS', stage='complete', outputs={
247
+ 'master': str(run / 'master.json'), 'variant': str(variant_path),
248
+ 'htmlCanvas': str(canvas), 'pdfDelivery': str(pdf), 'qa': str(run / 'qa.json'),
249
+ 'copies': [str(target) for _, target in pairs]},
250
+ sha256={'html': file_hash(canvas), 'pdf': file_hash(pdf), 'master': master['profileSha256']})
251
+ exit_code = 0
252
+ except Exception as error:
253
+ if report['status'] == 'RUNNING':
254
+ report['status'] = 'UNVERIFIED' if (report['stage'] == 'qa' and isinstance(error, (subprocess.TimeoutExpired, FileNotFoundError))) or not isinstance(error, (OSError, ValueError, TypeError, RuntimeError)) else 'FAIL'
255
+ report['errors'].append(str(error))
256
+ report['outputs'] = {}
257
+ exit_code = 2 if report['status'] == 'UNVERIFIED' else 1
258
+ finally:
259
+ report['finishedAt'] = datetime.now(timezone.utc).isoformat()
260
+ if run:
261
+ try:
262
+ write_json(run / 'manifest.json', report)
263
+ except OSError as error:
264
+ report.update(status='FAIL', outputs={})
265
+ report['errors'].append('Could not persist manifest: ' + str(error))
266
+ exit_code = 1
267
+ print(json.dumps(report, ensure_ascii=False, indent=None if args.quiet else 2))
268
+ return exit_code
269
+
270
+
271
+ if __name__ == '__main__':
272
+ sys.exit(main())
@@ -0,0 +1,65 @@
1
+ # Browser rendering and validation
2
+
3
+ All rendering/layout entry points delegate to `browser-engine.js`:
4
+
5
+ ```js
6
+ const { run } = require('./browser-engine');
7
+ const result = await run(htmlPath, { expectedPages: 1, autoHeal: false, outputPdf });
8
+ ```
9
+
10
+ ```sh
11
+ node scripts/rendering/browser-engine.js resume.html --expected-pages 1 --output resume.pdf
12
+ ```
13
+
14
+ CLI stdout is one JSON object with `status`, `errors`, `warnings`, `checks`,
15
+ `file`, and `expectedPages`. Exit codes: PASS=0, FAIL=1, UNVERIFIED=2.
16
+ `expectedPages` is an upper limit, restricted to 1 or 2.
17
+ Missing dependencies, browser/font/resource failures and parser failures cannot pass.
18
+ Validation without `outputPdf` still generates and inspects a fresh in-memory PDF.
19
+ No previous PDF is read to decide success. Output write/publication failures are
20
+ FAIL (exit 1); UNVERIFIED is reserved for unavailable verification facilities.
21
+ On successful export, `checks.output` contains `status: "PASS"`, `committed: true`,
22
+ the absolute `path`, and lowercase SHA-256 `pdfSha256` / `htmlSha256` digests of
23
+ the exact published PDF and final source HTML bytes. Without export, `committed`
24
+ is false and `path` is null, while digests identify the verified in-memory PDF
25
+ and final HTML.
26
+
27
+ Runtime dependencies: Playwright (or playwright-core with a browser), pdf-lib,
28
+ and pdfjs-dist with `legacy/build/pdf.mjs`; use a Node version supported by
29
+ pdfjs-dist. `CHROME_BIN` / `BROWSER_PATH` can select a Chromium executable.
30
+ Without overrides, bundled Chromium is tried first, then detected system
31
+ Chrome/Chromium/Edge/Brave installations (including PATH and macOS/Windows locations).
32
+ Dependency installation and package manifests are managed separately.
33
+
34
+ Checks use print media, await fonts and images, and inspect every `.resume-page`,
35
+ its descendants and text ranges against all four boundaries and rectangular
36
+ ancestor clips. Exactly one nonempty visible `h1.candidate-name` is required,
37
+ Nonempty print-hidden body text (display, visibility or ancestor opacity) fails,
38
+ even if other body text still survives PDF extraction. Also required:
39
+ at least 20 non-whitespace body characters beyond the name with no unbound
40
+ resume slot comments or language attribute. Literal brace syntax in user text is preserved. Each PDF page must correspond to an explicit `.resume-page`;
41
+ a single root naturally flowing into multiple PDF pages is rejected. DOM tolerance is 0.75 CSS pixels. Unsupported clipping returns
42
+ UNVERIFIED. The generated PDF must have 1–`expectedPages` portrait A4
43
+ pages (1 PDF point tolerance, including crop boxes). PDF.js extracts every page:
44
+ normalized character multiplicities and each DOM text fragment must survive on
45
+ the corresponding PDF page. NFKC and whitespace/control normalization tolerate
46
+ line wrapping and ligatures; a missing or reordered name cannot pass.
47
+
48
+ Autoheal only reduces existing numeric inline `:root` tokens through the bounded
49
+ ladder. Each candidate is loaded from a temporary sibling HTML file to preserve
50
+ relative assets and fully revalidated. No source change is published on failed
51
+ validation. On success, files are replaced by same-directory renames; an HTML
52
+ backup allows rollback if PDF publication fails. This is per-file atomicity with
53
+ rollback, not a crash-atomic transaction spanning two files. Temporary candidates
54
+ are cleaned up. Concurrent source edits detected before publication abort the run.
55
+
56
+ Isolated tests (real Chromium, temporary directories and injected PDF/I/O failures):
57
+
58
+ ```sh
59
+ node --test tests/rendering/browser-engine.test.js
60
+ python3 scripts/build/run-all-tests.py
61
+ ```
62
+
63
+ The Python suite runs the Node regressions through `tests/validation/test_auto_heal.py`.
64
+
65
+ Per-page text diagnostics include missingCodepoints and extractedRadicals (glyph and U+ codepoint). A warning about possible font mapping differences never changes FAIL to PASS. The pipeline and instantiator accept --font-preset arial-unicode to explicitly use locally installed Arial Unicode MS; this is not a browser-engine option. Missing explicit fonts remain UNVERIFIED.
@@ -0,0 +1,15 @@
1
+ export interface EngineOptions {
2
+ expectedPages?: number;
3
+ autoHeal?: boolean;
4
+ outputPdf?: string;
5
+ }
6
+ export interface EngineResult {
7
+ status: 'PASS' | 'FAIL' | 'UNVERIFIED';
8
+ file: string;
9
+ expectedPages: number;
10
+ errors: string[];
11
+ warnings: string[];
12
+ checks: Record<string, { status: 'PASS' | 'FAIL' | 'UNVERIFIED'; [key: string]: unknown }>;
13
+ }
14
+ export function run(html: string, options?: EngineOptions): Promise<EngineResult>;
15
+ export function cli(args?: string[], defaults?: EngineOptions): Promise<EngineResult>;