resumectl 0.0.0-stage → 0.1.0

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 (41) hide show
  1. package/GLOSSARY.md +19 -0
  2. package/LICENSE +21 -0
  3. package/README.md +63 -2
  4. package/docs/adr/0001-domain-first-organization.md +32 -0
  5. package/docs/agents/domain.md +12 -0
  6. package/docs/agents/issue-tracker.md +10 -0
  7. package/docs/agents/triage-labels.md +11 -0
  8. package/docs/api.md +16 -0
  9. package/docs/configuration.md +83 -0
  10. package/docs/quality.md +36 -0
  11. package/docs/rendering.md +68 -0
  12. package/docs/sans.png +0 -0
  13. package/docs/serif.png +0 -0
  14. package/docs/setup.md +31 -0
  15. package/package.json +48 -4
  16. package/resume.config.schema.json +85 -0
  17. package/resume.config.yaml +65 -0
  18. package/skills/resumectl/SKILL.md +143 -0
  19. package/src/cli/main.ts +108 -0
  20. package/src/cli.ts +14 -0
  21. package/src/content/tex.ts +107 -0
  22. package/src/errors.ts +3 -0
  23. package/src/index.ts +22 -0
  24. package/src/project/config.ts +265 -0
  25. package/src/project/doctor.ts +76 -0
  26. package/src/project/init.ts +78 -0
  27. package/src/quality/gates.ts +113 -0
  28. package/src/quality/pdf.ts +84 -0
  29. package/src/rendering/assets.ts +4 -0
  30. package/src/rendering/compile.ts +101 -0
  31. package/src/rendering/font-metrics.ts +89 -0
  32. package/src/rendering/font-width.ts +11 -0
  33. package/src/rendering/measurements.ts +81 -0
  34. package/src/rendering/metrics/sans.json +122 -0
  35. package/src/rendering/metrics/serif.json +121 -0
  36. package/template/fonts/Inter-430.ttf +0 -0
  37. package/template/fonts/Inter-500.ttf +0 -0
  38. package/template/fonts/Inter-600.ttf +0 -0
  39. package/template/fonts/OFL-Inter.txt +93 -0
  40. package/template/resume-measure.tex +74 -0
  41. package/template/resume.cls +203 -0
@@ -0,0 +1,265 @@
1
+ import { dirname, join, resolve } from "node:path";
2
+ import { existsSync } from "node:fs";
3
+ import { InputError } from "../errors.ts";
4
+ import { resolveInputs, sectionKey, stripComments } from "../content/tex.ts";
5
+
6
+ export interface LayoutOptions {
7
+ style: "serif" | "sans";
8
+ paper: "a4" | "letter";
9
+ scale: number;
10
+ tracking: number;
11
+ ratio: number;
12
+ margins: { top: string; bottom: string; left: string; right: string };
13
+ bulletSpacing: string;
14
+ bulletIndent: string;
15
+ sectionSpacing: { before: string; after: string };
16
+ }
17
+ export interface BulletOptions { maxLines: number; minLastLineFill: number }
18
+ export interface SectionOptions {
19
+ editable: boolean;
20
+ checks: BulletOptions | false;
21
+ }
22
+ export interface SectionInput {
23
+ editable?: boolean;
24
+ checks?: Partial<BulletOptions> | false;
25
+ }
26
+ export interface CheckOptions {
27
+ section?: string;
28
+ bullets: BulletOptions;
29
+ maxPages: number;
30
+ }
31
+ export interface ResumeConfig {
32
+ version: 1;
33
+ layout: LayoutOptions;
34
+ checks: CheckOptions;
35
+ sections: Record<string, SectionOptions>;
36
+ build: { outDir?: string; keepLogs: boolean };
37
+ }
38
+ export interface ConfigInput {
39
+ $schema?: string;
40
+ version?: 1;
41
+ layout?: Partial<Omit<LayoutOptions, "margins" | "sectionSpacing">> & {
42
+ margins?: Partial<LayoutOptions["margins"]>;
43
+ sectionSpacing?: Partial<LayoutOptions["sectionSpacing"]>;
44
+ };
45
+ checks?: Partial<Omit<CheckOptions, "bullets">> & { bullets?: Partial<CheckOptions["bullets"]> };
46
+ sections?: Record<string, SectionInput>;
47
+ build?: Partial<ResumeConfig["build"]>;
48
+ }
49
+ export const CONFIG_NAME = "resume.config.yaml";
50
+ export const SCHEMA_PATH = resolve(import.meta.dir, "../../resume.config.schema.json");
51
+ export const DEFAULT_CHECKS: CheckOptions = {
52
+ bullets: { maxLines: 2, minLastLineFill: 0.8 }, maxPages: 1,
53
+ };
54
+ export const DEFAULT_LAYOUT: LayoutOptions = {
55
+ style: "serif", paper: "a4", scale: 0.86, tracking: -1.5, ratio: 1.125,
56
+ margins: { top: "0.4in", bottom: "0.3in", left: "0.35in", right: "0.35in" },
57
+ bulletSpacing: "0.15em", bulletIndent: "1.5pc",
58
+ sectionSpacing: { before: "*1", after: "*1" },
59
+ };
60
+
61
+ type Shape = Record<string, unknown>;
62
+ function object(value: unknown, path: string, keys: string[]): Shape {
63
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new InputError(`${path} must be an object`);
64
+ for (const key of Object.keys(value)) if (!keys.includes(key)) throw new InputError(`unknown config field: ${path}.${key}`);
65
+ return value as Shape;
66
+ }
67
+ function number(value: unknown, path: string, min: number, max: number, integer = false) {
68
+ if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max || (integer && !Number.isInteger(value))) {
69
+ throw new InputError(`${path} must be ${integer ? "an integer" : "a number"} between ${min} and ${max}`);
70
+ }
71
+ }
72
+ function text(value: unknown, path: string) {
73
+ if (typeof value !== "string" || !value.trim() || /[\x00-\x1f]/.test(value)) throw new InputError(`${path} must be a non-empty string without control characters`);
74
+ }
75
+ function dimension(value: unknown, path: string, absolute = false, multiplier = false) {
76
+ const units = absolute ? "pt|mm|cm|in|pc" : "pt|mm|cm|in|pc|em|ex";
77
+ if (typeof value !== "string" || !(new RegExp(`^(?:\\d+(?:\\.\\d+)?|\\.\\d+)(?:${units})$`).test(value)
78
+ || (multiplier && /^\*(?:\d+(?:\.\d+)?|\.\d+)$/.test(value)))) {
79
+ throw new InputError(`${path} must be a non-negative length with units${multiplier ? " or a * multiplier" : ""}`);
80
+ }
81
+ }
82
+ function bulletOptions(value: unknown, path: string) {
83
+ const b = object(value, path, ["maxLines", "minLastLineFill"]);
84
+ if (b.maxLines !== undefined) number(b.maxLines, `${path}.maxLines`, 1, 100, true);
85
+ if (b.minLastLineFill !== undefined) number(b.minLastLineFill, `${path}.minLastLineFill`, 0, 1);
86
+ }
87
+ /** Validate both files and CLI/API overrides before any value reaches TeX. */
88
+ export function validateConfig(value: unknown): ConfigInput {
89
+ const root = object(value, "config", ["$schema", "version", "layout", "checks", "sections", "build"]);
90
+ if (root.version !== undefined && root.version !== 1) throw new InputError("config.version must be 1");
91
+ if (root.$schema !== undefined) text(root.$schema, "$schema");
92
+ if (root.layout !== undefined) {
93
+ const l = object(root.layout, "layout", ["style", "paper", "scale", "tracking", "ratio", "margins", "bulletSpacing", "bulletIndent", "sectionSpacing"]);
94
+ for (const [key, choices] of [["style", ["serif", "sans"]], ["paper", ["a4", "letter"]]] as const) {
95
+ if (l[key] !== undefined && !(choices as readonly unknown[]).includes(l[key])) throw new InputError(`layout.${key} must be ${choices.join(" or ")}`);
96
+ }
97
+ for (const [key, min, max] of [["scale", 0.1, 4], ["tracking", -10, 20], ["ratio", 1, 2]] as const) {
98
+ if (l[key] !== undefined) number(l[key], `layout.${key}`, min, max);
99
+ }
100
+ if (l.margins !== undefined) {
101
+ const m = object(l.margins, "layout.margins", ["top", "bottom", "left", "right"]);
102
+ for (const [k, v] of Object.entries(m)) dimension(v, `layout.margins.${k}`, true);
103
+ }
104
+ for (const key of ["bulletSpacing", "bulletIndent"]) if (l[key] !== undefined) dimension(l[key], `layout.${key}`);
105
+ if (l.sectionSpacing !== undefined) {
106
+ const s = object(l.sectionSpacing, "layout.sectionSpacing", ["before", "after"]);
107
+ for (const [k, v] of Object.entries(s)) dimension(v, `layout.sectionSpacing.${k}`, false, true);
108
+ }
109
+ }
110
+ if (root.checks !== undefined) {
111
+ const c = object(root.checks, "checks", ["section", "bullets", "maxPages"]);
112
+ if (c.section !== undefined) text(c.section, "checks.section");
113
+ if (c.maxPages !== undefined) number(c.maxPages, "checks.maxPages", 1, 100, true);
114
+ if (c.bullets !== undefined) bulletOptions(c.bullets, "checks.bullets");
115
+ }
116
+ if (root.sections !== undefined) {
117
+ const sections = object(root.sections, "sections", Object.keys(root.sections ?? {}));
118
+ const seen = new Set<string>();
119
+ for (const [name, value] of Object.entries(sections)) {
120
+ text(name, "section name");
121
+ const key = sectionKey(name);
122
+ if (!key || ["__PROTO__", "CONSTRUCTOR", "PROTOTYPE"].includes(key)) throw new InputError(`invalid section name: ${name}`);
123
+ if (seen.has(key)) throw new InputError(`duplicate section name: ${name}`);
124
+ seen.add(key);
125
+ const s = object(value, `sections.${name}`, ["editable", "checks"]);
126
+ if (s.editable !== undefined && typeof s.editable !== "boolean") throw new InputError(`sections.${name}.editable must be a boolean`);
127
+ if (s.checks !== undefined && s.checks !== false) bulletOptions(s.checks, `sections.${name}.checks`);
128
+ }
129
+ }
130
+ if (root.build !== undefined) {
131
+ const b = object(root.build, "build", ["outDir", "keepLogs"]);
132
+ if (b.outDir !== undefined) text(b.outDir, "build.outDir");
133
+ if (b.keepLogs !== undefined && typeof b.keepLogs !== "boolean") throw new InputError("build.keepLogs must be a boolean");
134
+ }
135
+ return value as ConfigInput;
136
+ }
137
+
138
+ const CLASS_KEYS: Record<string, string> = {
139
+ style: "style", paper: "paper", scale: "scale", tracking: "tracking", ratio: "ratio",
140
+ marginTop: "margins.top", marginBottom: "margins.bottom", marginLeft: "margins.left", marginRight: "margins.right",
141
+ bulletSpacing: "bulletSpacing", bulletIndent: "bulletIndent", sectionBefore: "sectionSpacing.before", sectionAfter: "sectionSpacing.after",
142
+ };
143
+ function setPath(target: Shape, path: string, value: unknown) {
144
+ const parts = path.split(".");
145
+ const last = parts.pop()!;
146
+ let node = target;
147
+ for (const part of parts) node = (node[part] ??= {}) as Shape;
148
+ node[last] = value;
149
+ }
150
+ export function readSourceLayout(tex: string): ConfigInput["layout"] {
151
+ const match = stripComments(tex).match(/\\documentclass\s*(?:\[([^\]]*)\])?\s*\{\s*resume\s*\}/);
152
+ if (!match) throw new InputError("expected \\documentclass{resume} with literal class options");
153
+ const layout: Shape = {};
154
+ for (const part of (match[1] ?? "").split(",").filter(p => p.trim())) {
155
+ const [name, raw] = part.split("=").map(p => p.trim());
156
+ const key = CLASS_KEYS[name];
157
+ if (!key || raw === undefined) throw new InputError(`unsupported resume class option: ${part.trim()}`);
158
+ setPath(layout, key, ["scale", "tracking", "ratio"].includes(name) ? Number(raw) : raw);
159
+ }
160
+ return validateConfig({ layout }).layout;
161
+ }
162
+ export function layoutToClassOptions(layout: LayoutOptions): string {
163
+ const l = layout as unknown as Shape;
164
+ return Object.entries(CLASS_KEYS).map(([key, path]) => {
165
+ let value: unknown = l;
166
+ for (const part of path.split(".")) value = (value as Shape)[part];
167
+ return `${key}=${value}`;
168
+ }).join(",");
169
+ }
170
+ async function discover(start: string): Promise<string | undefined> {
171
+ let dir = resolve(start);
172
+ for (;;) {
173
+ const candidates = [join(dir, CONFIG_NAME), join(dir, "resume.config.yml")];
174
+ const found = [];
175
+ for (const candidate of candidates) if (await Bun.file(candidate).exists()) found.push(candidate);
176
+ if (found.length > 1) throw new InputError(`both resume.config.yaml and resume.config.yml exist in ${dir}; select one with --config`);
177
+ if (found.length) return found[0];
178
+ // A repository's configuration must not accidentally inherit a sibling project's defaults.
179
+ if (existsSync(join(dir, ".git")) || dirname(dir) === dir) return undefined;
180
+ dir = dirname(dir);
181
+ }
182
+ }
183
+ export interface ResolvedConfig {
184
+ config: ResumeConfig;
185
+ configPath?: string;
186
+ sources: Record<string, string>;
187
+ }
188
+ export async function resolveConfig(options: { texPath?: string; configPath?: string | null; overrides?: ConfigInput; cwd?: string } = {}): Promise<ResolvedConfig> {
189
+ const cwd = options.cwd ?? process.cwd();
190
+ const texPath = options.texPath ? resolve(cwd, options.texPath) : undefined;
191
+ const configPath = options.configPath === null ? undefined : options.configPath ? resolve(cwd, options.configPath) : await discover(texPath ? dirname(texPath) : cwd);
192
+ let file: ConfigInput = {};
193
+ if (configPath) {
194
+ if (!/\.ya?ml$/i.test(configPath)) throw new InputError("config files must use .yaml or .yml");
195
+ if (!Bun.YAML) throw new InputError("YAML configuration requires Bun >= 1.3; upgrade Bun first");
196
+ if (!await Bun.file(configPath).exists()) throw new InputError(`config not found: ${configPath}`);
197
+ try { file = validateConfig(Bun.YAML.parse(await Bun.file(configPath).text()) ?? {}); }
198
+ catch (error) { throw new InputError(`${configPath}: ${(error as Error).message}`); }
199
+ }
200
+ let source: ConfigInput = {};
201
+ if (texPath) {
202
+ if (!await Bun.file(texPath).exists()) throw new InputError(`TeX source not found: ${texPath}`);
203
+ source = { layout: readSourceLayout(await resolveInputs(await Bun.file(texPath).text(), texPath)) };
204
+ }
205
+ const overrides = validateConfig(options.overrides ?? {});
206
+ const style = overrides.layout?.style ?? file.layout?.style ?? source.layout?.style ?? "serif";
207
+ const config: ResumeConfig = {
208
+ version: 1,
209
+ layout: structuredClone(DEFAULT_LAYOUT),
210
+ checks: structuredClone(DEFAULT_CHECKS),
211
+ sections: {},
212
+ build: { keepLogs: false },
213
+ };
214
+ if (style === "sans") config.layout.sectionSpacing = { before: "*2", after: "*0.8" };
215
+ const sources: Record<string, string> = {};
216
+ function merge(input: Shape, target: Shape, prefix: string, origin: string) {
217
+ for (const [key, value] of Object.entries(input)) {
218
+ if (key === "$schema") continue;
219
+ const path = prefix ? `${prefix}.${key}` : key;
220
+ if (value && typeof value === "object") {
221
+ if (!Object.hasOwn(target, key) || !target[key] || typeof target[key] !== "object") target[key] = {};
222
+ merge(value as Shape, target[key] as Shape, path, origin);
223
+ } else { target[key] = value; sources[path] = origin; }
224
+ }
225
+ }
226
+ // Section names are literal keys (including dots), normalized just like rendered headings.
227
+ const normalize = (input: ConfigInput): Shape => ({ ...input, ...(input.sections ? {
228
+ sections: Object.fromEntries(Object.entries(input.sections).map(([name, policy]) => [sectionKey(name), policy])),
229
+ } : {}) });
230
+ const target = config as unknown as Shape;
231
+ const normalizedOverrides = normalize(overrides);
232
+ merge(structuredClone(config) as unknown as Shape, target, "", "default");
233
+ merge(source as Shape, target, "", "documentclass");
234
+ merge(normalize(file), target, "", configPath ?? "config");
235
+ if (file.build?.outDir) config.build.outDir = resolve(dirname(configPath!), file.build.outDir);
236
+ merge(normalizedOverrides, target, "", "override");
237
+ for (const [name, policy] of Object.entries(config.sections)) {
238
+ policy.editable ??= false;
239
+ sources[`sections.${name}.editable`] ??= "default";
240
+ if (policy.checks === false) continue;
241
+ policy.checks = { ...config.checks.bullets, ...policy.checks };
242
+ for (const field of ["maxLines", "minLastLineFill"] as const) {
243
+ const path = `sections.${name}.checks.${field}`;
244
+ sources[path] ??= sources[`checks.bullets.${field}`];
245
+ // CLI/global API overrides apply to selected sections, above file-specific rules.
246
+ if (overrides.checks?.bullets?.[field] !== undefined) {
247
+ policy.checks[field] = overrides.checks.bullets[field]!;
248
+ sources[path] = "override";
249
+ }
250
+ const specific = (normalizedOverrides.sections as Record<string, SectionInput> | undefined)?.[name]?.checks;
251
+ if (specific && specific[field] !== undefined) policy.checks[field] = specific[field]!;
252
+ }
253
+ }
254
+ if (overrides.build?.outDir) config.build.outDir = resolve(cwd, overrides.build.outDir);
255
+ config.build.outDir ??= texPath ? dirname(texPath) : cwd;
256
+ sources["build.outDir"] ??= "source directory";
257
+ const pt = (length: string) => {
258
+ const [, value, unit] = length.match(/^([\d.]+)(\w+)$/)!;
259
+ return Number(value) * ({ pt: 1, in: 72.27, cm: 72.27 / 2.54, mm: 72.27 / 25.4, pc: 12 }[unit]!);
260
+ };
261
+ const m = config.layout.margins;
262
+ const [width, height] = config.layout.paper === "a4" ? [597.508, 845.047] : [614.295, 794.97];
263
+ if (pt(m.left) + pt(m.right) >= width || pt(m.top) + pt(m.bottom) >= height) throw new InputError("layout.margins leave no printable area");
264
+ return { config, configPath, sources };
265
+ }
@@ -0,0 +1,76 @@
1
+ import { join } from "node:path";
2
+ import { FONT_DIR, TEMPLATE_DIR } from "../rendering/assets.ts";
3
+ import { texEngine } from "../rendering/compile.ts";
4
+
5
+ export interface Diagnostic {
6
+ name: string;
7
+ ok: boolean;
8
+ detail: string;
9
+ fix?: string;
10
+ }
11
+ export interface DoctorReport { pass: boolean; checks: Diagnostic[] }
12
+
13
+ const TEX_FILES = [
14
+ "article.cls", "fontspec.sty", "kvoptions.sty", "geometry.sty", "titlesec.sty",
15
+ "enumitem.sty", "hyperref.sty", "url.sty", "tabularx.sty", "tikz.sty",
16
+ "texgyretermes-regular.otf", "texgyretermes-bold.otf",
17
+ "texgyretermes-italic.otf", "texgyretermes-bolditalic.otf",
18
+ ];
19
+
20
+ /** Inspect dependencies without installing anything or modifying the user's project. */
21
+ export async function doctor(): Promise<DoctorReport> {
22
+ const [major, minor] = Bun.version.split(".").map(Number);
23
+ const checks: Diagnostic[] = [{
24
+ name: "Bun", ok: (major > 1 || (major === 1 && minor >= 3)) && !!Bun.YAML,
25
+ detail: Bun.version, fix: "Install Bun >= 1.3.",
26
+ }];
27
+ const engine = texEngine();
28
+ if (engine === "tectonic") {
29
+ const path = Bun.which("tectonic");
30
+ const ok = !!path && Bun.spawnSync([path, "--help"], { stdout: "pipe", stderr: "pipe", timeout: 10_000 }).exitCode === 0;
31
+ checks.push({
32
+ name: "tectonic", ok, detail: ok ? `${path} (downloads TeX packages on first build)` : `${path ?? "not found in PATH"}: failed to run`,
33
+ fix: "Reinstall Tectonic: brew install tectonic.",
34
+ });
35
+ } else if (!engine) {
36
+ checks.push({
37
+ name: "TeX engine", ok: false, detail: "neither tectonic nor xelatex found in PATH",
38
+ fix: "Install Tectonic (brew install tectonic; TeX packages download on first build) or TinyTeX-1 (see docs/setup.md).",
39
+ });
40
+ } else {
41
+ for (const command of ["xelatex", "kpsewhich"]) {
42
+ const path = Bun.which(command);
43
+ let ok = false;
44
+ let detail = "not found in PATH";
45
+ if (path) {
46
+ try {
47
+ const result = Bun.spawnSync([path, "--version"], { stdout: "pipe", stderr: "pipe", timeout: 10_000 });
48
+ ok = result.exitCode === 0;
49
+ detail = ok ? `${path}: ${result.stdout.toString().split("\n")[0]}` : `${path}: failed to run`;
50
+ } catch (error) { detail = `${path}: ${(error as Error).message}`; }
51
+ }
52
+ checks.push({ name: command, ok, detail, fix: "Install TinyTeX-1 or TeX Live and add its bin directory to PATH." });
53
+ }
54
+ if (checks.find(c => c.name === "kpsewhich")!.ok) {
55
+ const missing = TEX_FILES.filter(file => {
56
+ const result = Bun.spawnSync([Bun.which("kpsewhich")!, file], { stdout: "pipe", stderr: "pipe", timeout: 10_000 });
57
+ return result.exitCode !== 0 || !result.stdout.toString().trim();
58
+ });
59
+ checks.push({
60
+ name: "TeX dependencies", ok: missing.length === 0,
61
+ detail: missing.length ? `missing: ${missing.join(", ")}` : `${TEX_FILES.length} required files found`,
62
+ fix: "Install the README's TeX packages; with TinyTeX-1: tlmgr install titlesec enumitem pgf tex-gyre.",
63
+ });
64
+ }
65
+ }
66
+ for (const path of [join(TEMPLATE_DIR, "resume.cls"), join(TEMPLATE_DIR, "resume-measure.tex"),
67
+ ...[430, 500, 600].map(weight => join(FONT_DIR, `Inter-${weight}.ttf`))]) {
68
+ checks.push({ name: "Bundled asset", ok: await Bun.file(path).exists(), detail: path, fix: "Restore the template directory or reinstall resumectl." });
69
+ }
70
+ return { pass: checks.every(c => c.ok), checks };
71
+ }
72
+
73
+ export function formatDoctor(report: DoctorReport): string {
74
+ return report.checks.map(c => `${c.ok ? "PASS" : "FAIL"} ${c.name}: ${c.detail}${!c.ok && c.fix ? `\n ${c.fix}` : ""}`).join("\n")
75
+ + `\n\n${report.pass ? "OK — build dependencies are available." : "Fix the failed checks before building."}`;
76
+ }
@@ -0,0 +1,78 @@
1
+ import { lstat, mkdir, open, rm } from "node:fs/promises";
2
+ import { dirname, join, relative, resolve } from "node:path";
3
+ import { InputError } from "../errors.ts";
4
+ import { CONFIG_NAME, DEFAULT_CHECKS, SCHEMA_PATH, validateConfig, type ConfigInput, type LayoutOptions } from "./config.ts";
5
+
6
+ type StarterOptions = Pick<LayoutOptions, "style" | "paper">;
7
+
8
+ function starterConfig(options: Partial<StarterOptions>): ConfigInput {
9
+ return validateConfig({
10
+ version: 1,
11
+ layout: { style: "serif", paper: "a4", ...options },
12
+ checks: structuredClone(DEFAULT_CHECKS),
13
+ sections: { "WORK EXPERIENCE": { editable: true, checks: { ...DEFAULT_CHECKS.bullets } } },
14
+ build: { outDir: "./output", keepLogs: false },
15
+ });
16
+ }
17
+
18
+ function configText(path: string, config: ConfigInput): string {
19
+ return `# yaml-language-server: $schema=${relative(dirname(path), SCHEMA_PATH)}\n`
20
+ + "# CLI > YAML > documentclass > defaults. Lengths include units.\n"
21
+ + Bun.YAML.stringify(config, null, 2).replace(/[ \t]+$/gm, "") + "\n";
22
+ }
23
+
24
+ async function writeNew(path: string, text: string): Promise<void> {
25
+ let file;
26
+ try { file = await open(path, "wx"); }
27
+ catch (error) {
28
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new InputError(`file already exists: ${path}`);
29
+ throw error;
30
+ }
31
+ try { await file.writeFile(text); }
32
+ catch (error) { await rm(path, { force: true }); throw error; }
33
+ finally { await file.close(); }
34
+ }
35
+
36
+ export async function initConfig(path = CONFIG_NAME): Promise<{ configPath: string; config: ConfigInput }> {
37
+ path = resolve(path);
38
+ if (!/\.ya?ml$/i.test(path)) throw new InputError("config files must use .yaml or .yml");
39
+ const config = starterConfig({});
40
+ await writeNew(path, configText(path, config));
41
+ return { configPath: path, config };
42
+ }
43
+
44
+ /** Create editable source and policy together; never replace an existing résumé or config. */
45
+ export async function initProject(directory = ".", options: Partial<StarterOptions> = {}) {
46
+ const config = starterConfig(options);
47
+ const target = resolve(directory);
48
+ const texPath = join(target, "resume.tex");
49
+ const configPath = join(target, CONFIG_NAME);
50
+ for (const path of [texPath, configPath, join(target, "resume.config.yml")]) {
51
+ const existing = await lstat(path).catch((error: NodeJS.ErrnoException) => {
52
+ if (error.code === "ENOENT") return undefined;
53
+ throw error;
54
+ });
55
+ if (existing) throw new InputError(`file already exists: ${path}`);
56
+ }
57
+ const source = String.raw`% Replace every placeholder with verified career facts before sharing.
58
+ \documentclass[style=${config.layout!.style},paper=${config.layout!.paper}]{resume}
59
+ \begin{document}
60
+ \name{YOUR NAME}
61
+ \basicInfo{\href{mailto:you@example.com}{you@example.com}\sep City, Country}
62
+
63
+ \section{WORK EXPERIENCE}
64
+ \datedsubsection{\textbf{Company}}{Dates}
65
+ \datedline{\role{Role}}{Location}
66
+ \begin{itemize}
67
+ \item Replace this sample with a verified accomplishment: describe your action, technical approach, and measurable impact.
68
+ \end{itemize}
69
+
70
+ % Add other sections and their editing/checking policies in resume.config.yaml.
71
+ \end{document}
72
+ `;
73
+ await mkdir(target, { recursive: true });
74
+ await writeNew(texPath, source);
75
+ try { await writeNew(configPath, configText(configPath, config)); }
76
+ catch (error) { await rm(texPath, { force: true }); throw error; }
77
+ return { directory: target, texPath, configPath };
78
+ }
@@ -0,0 +1,113 @@
1
+ // Quality gates based on the actual rendered paragraphs, PDF pages and text maps.
2
+ import { DEFAULT_CHECKS, layoutToClassOptions, validateConfig, type BulletOptions, type CheckOptions, type LayoutOptions, type SectionInput } from "../project/config.ts";
3
+ import { InputError } from "../errors.ts";
4
+ import { hash, readManifest, type RenderedBullet } from "../rendering/measurements.ts";
5
+ import { countPdfPages, findPrivateUseMappings } from "./pdf.ts";
6
+ import { type ClassOptions, extractSectionEntries, resolveInputs, sectionKey, stripToVisible } from "../content/tex.ts";
7
+
8
+ export type GateOptions = CheckOptions & { sections?: Record<string, SectionInput> };
9
+ export type GateOverrides = Partial<Omit<GateOptions, "bullets">> & { bullets?: Partial<GateOptions["bullets"]> };
10
+ export const DEFAULT_GATE_OPTIONS = DEFAULT_CHECKS;
11
+
12
+ export interface BulletResult extends RenderedBullet {
13
+ index: number;
14
+ text: string;
15
+ status: "ok" | "too-many-lines" | "sparse-last-line" | "overflow";
16
+ }
17
+ export interface GateReport {
18
+ classOptions: ClassOptions;
19
+ layout: LayoutOptions;
20
+ options: GateOptions;
21
+ bullets: BulletResult[];
22
+ sections: SectionResult[];
23
+ pages: number;
24
+ privateUse: number[];
25
+ overflows: string[];
26
+ bulletsPass: boolean;
27
+ pagesPass: boolean;
28
+ textPass: boolean;
29
+ pass: boolean;
30
+ }
31
+ export interface SectionResult {
32
+ section: string;
33
+ editable: boolean;
34
+ checks: BulletOptions | false;
35
+ bullets: BulletResult[];
36
+ pass: boolean;
37
+ }
38
+ export function checkBullets(rendered: RenderedBullet[], options: GateOptions, texts: string[] = []): BulletResult[] {
39
+ const section = options.section ?? "WORK EXPERIENCE";
40
+ const bullets = rendered.filter(b => sectionKey(b.section) === sectionKey(section));
41
+ return bullets.map((b, i) => {
42
+ if (b.unsupported) throw new InputError(`entry ${i + 1} in ${section} contains a nested list; use flat itemize bullets`);
43
+ const status = b.lastLineWidthPt > b.lineWidthPt + 0.1 ? "overflow"
44
+ : b.lines > options.bullets.maxLines ? "too-many-lines"
45
+ : b.lastLineFill + 1e-6 < options.bullets.minLastLineFill ? "sparse-last-line" : "ok";
46
+ return { ...b, index: i + 1, text: texts.length === bullets.length ? stripToVisible(texts[i]) : "", status };
47
+ });
48
+ }
49
+ export function parseLogPageCount(log: string): number | null {
50
+ const m = log.replace(/\r?\n/g, "").match(/Output written on .*?\((\d+) pages?\)/);
51
+ return m ? Number(m[1]) : null;
52
+ }
53
+ export async function runGates(texPath: string, pdfPath: string, overrides: GateOverrides = {}, expectedLayout?: LayoutOptions): Promise<GateReport> {
54
+ const { sections: sectionPolicies, ...checkOverrides } = overrides;
55
+ validateConfig({ checks: checkOverrides, ...(sectionPolicies !== undefined ? { sections: sectionPolicies } : {}) });
56
+ const options = { ...DEFAULT_CHECKS, ...overrides, bullets: { ...DEFAULT_CHECKS.bullets, ...overrides.bullets } };
57
+ if (!await Bun.file(texPath).exists()) throw new InputError(`TeX source not found: ${texPath}`);
58
+ if (!await Bun.file(pdfPath).exists()) throw new InputError(`PDF not found: ${pdfPath}`);
59
+ const tex = await resolveInputs(await Bun.file(texPath).text(), texPath);
60
+ const bytes = await Bun.file(pdfPath).bytes();
61
+ const manifest = await readManifest(pdfPath);
62
+ validateConfig({ layout: manifest.layout });
63
+ if (hash(bytes) !== manifest.pdfHash || hash(tex) !== manifest.sourceHash) {
64
+ throw new InputError("PDF/source no longer match the saved render measurements; rebuild the PDF");
65
+ }
66
+ if (expectedLayout && layoutToClassOptions(expectedLayout) !== layoutToClassOptions(manifest.layout)) {
67
+ throw new InputError("layout config differs from the PDF's build config; rebuild or use the same layout options");
68
+ }
69
+ const policies = new Map(Object.entries(sectionPolicies ?? {}).map(([name, policy]) => [sectionKey(name), policy]));
70
+ // A legacy selector (or --section) narrows the report; otherwise check every configured section.
71
+ const selected = options.section ? [sectionKey(options.section)] : policies.size ? [...policies.keys()] : ["WORK EXPERIENCE"];
72
+ const sections: SectionResult[] = selected.map(section => {
73
+ const policy = policies.get(section);
74
+ const editable = policy?.editable ?? false;
75
+ if (policy?.checks === false) return { section, editable, checks: false, bullets: [], pass: true };
76
+ const checks = { ...options.bullets, ...policy?.checks };
77
+ const bullets = checkBullets(manifest.bullets, { ...options, section, bullets: checks }, extractSectionEntries(tex, section));
78
+ if (!bullets.length) throw new InputError(`found no rendered bullets or entries in section "${section}"; check the section name or wrap prose/table descriptions in \\resumeentry{...}`);
79
+ return { section, editable, checks, bullets, pass: bullets.every(b => b.status === "ok") };
80
+ });
81
+ const bullets = sections.flatMap(s => s.bullets);
82
+ let pages = countPdfPages(bytes);
83
+ if (pages === null) {
84
+ const log = Bun.file(pdfPath.replace(/\.pdf$/i, ".log"));
85
+ if (await log.exists()) pages = parseLogPageCount(await log.text());
86
+ }
87
+ if (pages === null) throw new InputError(`could not read the page count of ${pdfPath}`);
88
+ const privateUse = findPrivateUseMappings(bytes);
89
+ const bulletsPass = bullets.every(b => b.status === "ok") && manifest.overflows.length === 0;
90
+ const pagesPass = pages <= options.maxPages;
91
+ const textPass = privateUse.length === 0;
92
+ const layout = manifest.layout;
93
+ const classOptions: ClassOptions = { style: layout.style, scale: layout.style === "sans" ? layout.scale : 1, tracking: layout.style === "sans" ? layout.tracking : 0 };
94
+ return { classOptions, layout, options, sections, bullets, pages, privateUse, overflows: manifest.overflows, bulletsPass, pagesPass, textPass, pass: bulletsPass && pagesPass && textPass };
95
+ }
96
+ export function formatReport(r: GateReport): string {
97
+ return [
98
+ `Gate 1 — rendered content length (${r.layout.style}, ${r.layout.paper})`,
99
+ ...r.sections.flatMap(s => s.checks === false ? [` ${s.section}: SKIP (checks disabled; AI editable: ${s.editable})`] : [
100
+ ` ${s.section}: ≤ ${s.checks.maxLines} line${s.checks.maxLines === 1 ? "" : "s"}, last line ≥ ${Math.round(s.checks.minLastLineFill * 100)}% full; AI editable: ${s.editable}`,
101
+ ` ${s.bullets.length} ${s.bullets.length === 1 ? "entry" : "entries"} — ${s.pass ? "PASS" : "FAIL"}`,
102
+ ...s.bullets.filter(b => b.status !== "ok").map(b => ` ${b.status.toUpperCase()} ${s.section} #${b.index}: ${b.lines} lines, last line ${(b.lastLineFill * 100).toFixed(1)}% full${b.text ? ` — ${b.text.slice(0, 80)}${b.text.length > 80 ? "…" : ""}` : ""}`),
103
+ ]),
104
+ ...r.overflows.map(s => ` OVERFLOW: ${s}`),
105
+ ` ${r.bulletsPass ? "PASS" : "FAIL"}`, "",
106
+ `Gate 2 — at most ${r.options.maxPages} page${r.options.maxPages === 1 ? "" : "s"}`,
107
+ ` pages ${r.pages}`, ` ${r.pagesPass ? "PASS" : "FAIL"}`, "",
108
+ "Gate 3 — text copies cleanly (no private-use characters)",
109
+ ...r.privateUse.map(cp => ` private-use mapping: U+${cp.toString(16).toUpperCase()}`),
110
+ ` ${r.textPass ? "PASS" : "FAIL"}`, "",
111
+ r.pass ? "OK — all gates passed." : "FAIL — revise the flagged content or layout, then rebuild.",
112
+ ].join("\n");
113
+ }
@@ -0,0 +1,84 @@
1
+ // Facts read straight from the compiled PDF, including PDFs whose objects live
2
+ // in compressed object streams (xdvipdfmx's default).
3
+ import { inflateSync } from "node:zlib";
4
+
5
+ /** Every inflatable stream in the PDF, as latin1 text. Non-Flate streams (fonts, images) are skipped. */
6
+ function* inflatedStreams(bytes: Uint8Array, raw: string): Generator<string> {
7
+ // (?<!end): "endstream\n" contains "stream\n"; matching it would misalign every stream after it.
8
+ const streamRe = /(?<!end)stream\r?\n/g;
9
+ for (let m = streamRe.exec(raw); m; m = streamRe.exec(raw)) {
10
+ const start = m.index + m[0].length;
11
+ const end = raw.indexOf("endstream", start);
12
+ if (end < 0) return;
13
+ try {
14
+ yield inflateSync(bytes.subarray(start, end)).toString("latin1");
15
+ } catch {
16
+ // Not a Flate stream.
17
+ }
18
+ streamRe.lastIndex = end;
19
+ }
20
+ }
21
+
22
+ const PAGES_COUNT = /\/Type\s*\/Pages\b[^]*?\/Count\s+(\d+)|\/Count\s+(\d+)[^]*?\/Type\s*\/Pages\b/g;
23
+
24
+ function maxCount(text: string): number | null {
25
+ let best: number | null = null;
26
+ // Scan each dictionary separately so /Count never pairs with a neighbour's /Type.
27
+ for (const dict of text.split(/>>|<</)) {
28
+ for (const m of dict.matchAll(PAGES_COUNT)) {
29
+ const n = Number(m[1] ?? m[2]);
30
+ if (best === null || n > best) best = n;
31
+ }
32
+ }
33
+ return best;
34
+ }
35
+
36
+ /** The root /Pages node holds the total, so the largest /Count wins. */
37
+ export function countPdfPages(bytes: Uint8Array): number | null {
38
+ const raw = Buffer.from(bytes).toString("latin1");
39
+ let best = maxCount(raw);
40
+ for (const text of inflatedStreams(bytes, raw)) {
41
+ const n = maxCount(text);
42
+ if (n !== null && (best === null || n > best)) best = n;
43
+ }
44
+ return best;
45
+ }
46
+
47
+ const isPrivateUse = (cp: number) => (cp >= 0xe000 && cp <= 0xf8ff) || cp >= 0xf0000;
48
+
49
+ /** First code point of a UTF-16BE hex string from a ToUnicode CMap, e.g. "D83DDE00" → 0x1F600. */
50
+ function firstCodePoint(hex: string): number {
51
+ const hi = parseInt(hex.slice(0, 4), 16);
52
+ if (hi >= 0xd800 && hi <= 0xdbff && hex.length >= 8) {
53
+ return 0x10000 + ((hi - 0xd800) << 10) + (parseInt(hex.slice(4, 8), 16) - 0xdc00);
54
+ }
55
+ return hi;
56
+ }
57
+
58
+ /**
59
+ * Private-use code points that glyphs map to in the PDF's ToUnicode CMaps.
60
+ * Text drawn with such glyphs looks right but copies out (and parses, for ATS)
61
+ * as garbage — typically OpenType alternates such as case-sensitive punctuation.
62
+ */
63
+ export function findPrivateUseMappings(bytes: Uint8Array): number[] {
64
+ const raw = Buffer.from(bytes).toString("latin1");
65
+ const found = new Set<number>();
66
+ for (const cmap of inflatedStreams(bytes, raw)) {
67
+ if (!cmap.includes("begincmap")) continue;
68
+ for (const [, block] of cmap.matchAll(/beginbfchar([^]*?)endbfchar/g)) {
69
+ for (const [, , dst] of block.matchAll(/<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>/g)) {
70
+ const cp = firstCodePoint(dst);
71
+ if (isPrivateUse(cp)) found.add(cp);
72
+ }
73
+ }
74
+ for (const [, block] of cmap.matchAll(/beginbfrange([^]*?)endbfrange/g)) {
75
+ for (const [, lo, hi, dst, list] of block.matchAll(/<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>\s*(?:<([0-9A-Fa-f]+)>|\[([^\]]*)\])/g)) {
76
+ const targets = dst !== undefined
77
+ ? Array.from({ length: parseInt(hi, 16) - parseInt(lo, 16) + 1 }, (_, i) => firstCodePoint(dst) + i)
78
+ : [...list.matchAll(/<([0-9A-Fa-f]+)>/g)].map(m => firstCodePoint(m[1]));
79
+ for (const cp of targets) if (isPrivateUse(cp)) found.add(cp);
80
+ }
81
+ }
82
+ }
83
+ return [...found].sort((a, b) => a - b);
84
+ }
@@ -0,0 +1,4 @@
1
+ import { join, resolve } from "node:path";
2
+
3
+ export const TEMPLATE_DIR = resolve(import.meta.dir, "../../template");
4
+ export const FONT_DIR = join(TEMPLATE_DIR, "fonts");