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.
- package/AGENT.md +276 -0
- package/ARCHITECTURE.md +216 -0
- package/README.md +34 -54
- package/README.zh-CN.md +35 -57
- package/SKILL.md +28 -137
- package/agents/claude/knowme-careerforge.md +2 -16
- package/agents/codex/knowme-careerforge.yaml +5 -7
- package/agents/cursor/knowme-careerforge.mdc +4 -9
- package/agents/opencode/skill.yaml +1 -1
- package/agents/windsurf/knowme-careerforge.rules +2 -12
- package/dist/cli/src/commands/init.js +9 -0
- package/dist/cli/src/index.js +40 -19
- package/dist/scripts/rendering/engine-loader.d.ts +2 -0
- package/dist/scripts/rendering/engine-loader.js +46 -0
- package/dist/scripts/rendering/render-pdf.d.ts +3 -8
- package/dist/scripts/rendering/render-pdf.js +7 -103
- package/dist/scripts/rendering/runtime.d.ts +9 -0
- package/dist/scripts/rendering/runtime.js +63 -0
- package/dist/scripts/validation/validate-ats.d.ts +2 -22
- package/dist/scripts/validation/validate-ats.js +6 -168
- package/dist/scripts/validation/validate-layout.d.ts +2 -23
- package/dist/scripts/validation/validate-layout.js +5 -159
- package/package.json +16 -4
- package/references/01-evidence-mining.md +5 -0
- package/references/02-career-goal.md +38 -0
- package/references/03-jd-analysis.md +44 -0
- package/references/04-template-selection.md +43 -0
- package/references/05-html-canvas-tokens.md +12 -0
- package/references/06-qa-and-rendering.md +14 -0
- package/references/07-artifact-contract.md +64 -0
- package/scripts/Agent.md +9 -136
- package/scripts/build/build-gallery.py +66 -56
- package/scripts/build/run-all-tests.py +1 -0
- package/scripts/build/sync-version.py +37 -10
- package/scripts/contracts/profile.py +101 -0
- package/scripts/evidence/extract-evidence.py +109 -62
- package/scripts/pipeline/forge.py +266 -136
- package/scripts/rendering/README.md +65 -0
- package/scripts/rendering/browser-engine.d.ts +15 -0
- package/scripts/rendering/browser-engine.js +366 -0
- package/scripts/rendering/browser_engine.py +39 -0
- package/scripts/rendering/engine-loader.ts +11 -0
- package/scripts/rendering/render-pdf.py +26 -151
- package/scripts/rendering/render-pdf.ts +7 -83
- package/scripts/template/instantiate-resume.py +266 -167
- package/scripts/template/search-template.py +367 -89
- package/scripts/validation/measure-dom.js +5 -0
- package/scripts/validation/validate-ats.ts +6 -174
- package/scripts/validation/validate-layout.ts +5 -159
- package/scripts/validation/validate-resume.py +23 -134
- package/skill.json +1 -1
- package/src/knowledge/index.json +390 -10
- package/src/knowledge/resume-schema.json +377 -71
- package/src/knowledge/templates.json +388 -8
- package/src/references/01-evidence-mining.md +5 -0
- package/src/references/02-career-goal.md +38 -0
- package/src/references/03-jd-analysis.md +44 -0
- package/src/references/04-template-selection.md +43 -0
- package/src/references/05-html-canvas-tokens.md +12 -0
- package/src/references/06-qa-and-rendering.md +14 -0
- package/src/references/07-artifact-contract.md +64 -0
- package/src/templates/Agent.md +11 -0
- package/src/templates/academic-research/README.md +25 -0
- package/src/templates/academic-research/canvas.html +21 -0
- package/src/templates/academic-research/metadata.json +38 -0
- package/src/templates/academic-research/sample-profile.json +86 -0
- package/src/templates/academic-research/style.css +213 -0
- package/src/templates/classic/README.md +9 -0
- package/src/templates/classic/canvas.html +21 -0
- package/src/templates/classic/metadata.json +3 -0
- package/src/templates/classic/sample-profile.json +86 -0
- package/src/templates/classic/style.css +35 -54
- package/src/templates/common/base.css +182 -0
- package/src/templates/common/canvas-bindings.css +41 -0
- package/src/templates/common/resume-contract.md +8 -0
- package/src/templates/compact-dense/README.md +25 -0
- package/src/templates/compact-dense/canvas.html +14 -0
- package/src/templates/compact-dense/metadata.json +38 -0
- package/src/templates/compact-dense/sample-profile.json +86 -0
- package/src/templates/compact-dense/style.css +218 -0
- package/src/templates/creative-tech/README.md +26 -0
- package/src/templates/creative-tech/canvas.html +15 -0
- package/src/templates/creative-tech/metadata.json +39 -0
- package/src/templates/creative-tech/sample-profile.json +86 -0
- package/src/templates/creative-tech/style.css +257 -0
- package/src/templates/data-analyst/README.md +26 -0
- package/src/templates/data-analyst/canvas.html +15 -0
- package/src/templates/data-analyst/metadata.json +39 -0
- package/src/templates/data-analyst/sample-profile.json +86 -0
- package/src/templates/data-analyst/style.css +285 -0
- package/src/templates/executive/README.md +9 -0
- package/src/templates/executive/canvas.html +15 -0
- package/src/templates/executive/sample-profile.json +86 -0
- package/src/templates/executive/style.css +83 -74
- package/src/templates/international-flow/README.md +26 -0
- package/src/templates/international-flow/canvas.html +21 -0
- package/src/templates/international-flow/metadata.json +37 -0
- package/src/templates/international-flow/sample-profile.json +86 -0
- package/src/templates/international-flow/style.css +208 -0
- package/src/templates/minimal/README.md +9 -0
- package/src/templates/minimal/canvas.html +21 -0
- package/src/templates/minimal/sample-profile.json +86 -0
- package/src/templates/minimal/style.css +54 -72
- package/src/templates/modern/README.md +9 -0
- package/src/templates/modern/canvas.html +15 -0
- package/src/templates/modern/sample-profile.json +86 -0
- package/src/templates/modern/style.css +78 -71
- package/src/templates/startup-generalist/README.md +25 -0
- package/src/templates/startup-generalist/canvas.html +21 -0
- package/src/templates/startup-generalist/metadata.json +37 -0
- package/src/templates/startup-generalist/sample-profile.json +86 -0
- package/src/templates/startup-generalist/style.css +237 -0
- package/src/references/ats.md +0 -17
- package/src/references/evidence-first.md +0 -22
- package/src/references/pdf.md +0 -40
- package/src/references/resume-writing.md +0 -25
- package/src/references/template-design.md +0 -57
- package/src/templates/classic/template.html +0 -133
- package/src/templates/executive/template.html +0 -101
- package/src/templates/minimal/template.html +0 -114
- package/src/templates/modern/template.html +0 -139
- package/src/workflows/analyze-job.md +0 -16
- package/src/workflows/define-goal.md +0 -16
- package/src/workflows/forge-resume.md +0 -25
- package/src/workflows/know-me.md +0 -21
- package/src/workflows/position.md +0 -19
- package/src/workflows/review.md +0 -31
|
@@ -1,142 +1,272 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
if
|
|
142
|
-
|
|
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>;
|