job-forge 2.14.26 → 2.14.28
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/.cursor/rules/main.mdc +9 -3
- package/.opencode/skills/job-forge.md +28 -6
- package/AGENTS.md +9 -3
- package/CLAUDE.md +9 -3
- package/README.md +6 -4
- package/bin/create-job-forge.mjs +11 -0
- package/bin/job-forge.mjs +57 -0
- package/docs/ARCHITECTURE.md +7 -1
- package/docs/CUSTOMIZATION.md +9 -1
- package/docs/README.md +1 -1
- package/docs/SETUP.md +4 -0
- package/iso/commands/job-forge.md +28 -6
- package/iso/instructions.md +9 -3
- package/lib/jobforge-cache.mjs +9 -4
- package/lib/jobforge-canon.mjs +88 -0
- package/lib/jobforge-index.mjs +77 -1
- package/lib/jobforge-ledger.mjs +33 -15
- package/lib/jobforge-preflight.mjs +29 -0
- package/package.json +10 -1
- package/scripts/canon.mjs +178 -0
- package/scripts/ledger.mjs +27 -2
- package/scripts/preflight.mjs +142 -0
- package/templates/canon.json +65 -0
- package/templates/capabilities.json +8 -2
- package/templates/migrations.json +7 -0
- package/templates/preflight.json +59 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import {
|
|
4
|
+
canonicalizeCompany,
|
|
5
|
+
canonicalizeCompanyRole,
|
|
6
|
+
canonicalizeEntity,
|
|
7
|
+
canonicalizeRole,
|
|
8
|
+
canonicalizeUrl,
|
|
9
|
+
compareCanon,
|
|
10
|
+
loadCanonConfig,
|
|
11
|
+
parseJson,
|
|
12
|
+
resolveProfile,
|
|
13
|
+
} from '@razroo/iso-canon';
|
|
14
|
+
|
|
15
|
+
export const CANON_CONFIG_FILE = 'templates/canon.json';
|
|
16
|
+
export const CANON_PROFILE = 'jobforge';
|
|
17
|
+
|
|
18
|
+
export function resolveProjectDir(projectDir = process.env.JOB_FORGE_PROJECT || process.cwd()) {
|
|
19
|
+
return projectDir;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function jobForgeCanonConfigPath(projectDir = resolveProjectDir()) {
|
|
23
|
+
return process.env.JOB_FORGE_CANON_CONFIG || join(projectDir, CANON_CONFIG_FILE);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function readJobForgeCanonConfig(projectDir = resolveProjectDir()) {
|
|
27
|
+
const path = jobForgeCanonConfigPath(projectDir);
|
|
28
|
+
return loadCanonConfig(parseJson(readFileSync(path, 'utf8'), path), path);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function jobForgeCanonProfile(projectDir = resolveProjectDir()) {
|
|
32
|
+
return resolveProfile(readJobForgeCanonConfig(projectDir), process.env.JOB_FORGE_CANON_PROFILE || CANON_PROFILE);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function canonicalizeJobForgeUrl(url, projectDir = resolveProjectDir()) {
|
|
36
|
+
return canonicalizeUrl(url, jobForgeCanonProfile(projectDir));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function canonicalizeJobForgeCompany(company, projectDir = resolveProjectDir()) {
|
|
40
|
+
return canonicalizeCompany(company, jobForgeCanonProfile(projectDir));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function canonicalizeJobForgeRole(role, projectDir = resolveProjectDir()) {
|
|
44
|
+
return canonicalizeRole(role, jobForgeCanonProfile(projectDir));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function canonicalizeJobForgeCompanyRole(company, role, projectDir = resolveProjectDir()) {
|
|
48
|
+
return canonicalizeCompanyRole(company, role, jobForgeCanonProfile(projectDir));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function canonicalizeJobForgeEntity(type, input, projectDir = resolveProjectDir()) {
|
|
52
|
+
return canonicalizeEntity(type, input, jobForgeCanonProfile(projectDir));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function compareJobForgeCanon(type, left, right, projectDir = resolveProjectDir()) {
|
|
56
|
+
return compareCanon(type, left, right, jobForgeCanonProfile(projectDir));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function jobForgeUrlKey(url, projectDir = resolveProjectDir()) {
|
|
60
|
+
return canonicalizeJobForgeUrl(url, projectDir).key;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function jobForgeCompanyRoleKey(company, role, projectDir = resolveProjectDir()) {
|
|
64
|
+
return canonicalizeJobForgeCompanyRole(company, role, projectDir).key;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function jobForgeApplicationSubject(company, role, projectDir = resolveProjectDir()) {
|
|
68
|
+
const companyKey = canonicalizeJobForgeCompany(company, projectDir).key.slice('company:'.length);
|
|
69
|
+
const roleKey = canonicalizeJobForgeRole(role, projectDir).key.slice('role:'.length);
|
|
70
|
+
return `application:${companyKey}:${roleKey}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function legacyCompanyRoleKey(company, role) {
|
|
74
|
+
return `company-role:${legacySlugPart(company)}:${legacySlugPart(role)}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function legacyUrlKey(url) {
|
|
78
|
+
return `url:${String(url || '').trim()}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function legacySlugPart(value) {
|
|
82
|
+
const slug = String(value || 'unknown')
|
|
83
|
+
.toLowerCase()
|
|
84
|
+
.replace(/&/g, ' and ')
|
|
85
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
86
|
+
.replace(/^-+|-+$/g, '');
|
|
87
|
+
return slug || 'unknown';
|
|
88
|
+
}
|
package/lib/jobforge-index.mjs
CHANGED
|
@@ -6,8 +6,15 @@ import {
|
|
|
6
6
|
loadIndexConfig,
|
|
7
7
|
parseJson,
|
|
8
8
|
queryIndex,
|
|
9
|
+
recordId,
|
|
9
10
|
verifyIndex,
|
|
10
11
|
} from '@razroo/iso-index';
|
|
12
|
+
import {
|
|
13
|
+
jobForgeCompanyRoleKey,
|
|
14
|
+
jobForgeUrlKey,
|
|
15
|
+
legacyCompanyRoleKey,
|
|
16
|
+
legacyUrlKey,
|
|
17
|
+
} from './jobforge-canon.mjs';
|
|
11
18
|
|
|
12
19
|
export const INDEX_FILE = '.jobforge-index.json';
|
|
13
20
|
export const INDEX_CONFIG_FILE = 'templates/index.json';
|
|
@@ -35,7 +42,7 @@ export function readJobForgeIndexConfig(projectDir = resolveProjectDir()) {
|
|
|
35
42
|
|
|
36
43
|
export function buildJobForgeIndex(options = {}, projectDir = resolveProjectDir()) {
|
|
37
44
|
const config = readJobForgeIndexConfig(projectDir);
|
|
38
|
-
const index = buildIndex(config, { root: projectDir });
|
|
45
|
+
const index = canonicalizeJobForgeIndex(buildIndex(config, { root: projectDir }), projectDir);
|
|
39
46
|
const out = options.out || jobForgeIndexPath(projectDir);
|
|
40
47
|
if (options.write !== false) {
|
|
41
48
|
writeFileSync(out, `${JSON.stringify(index, null, 2)}\n`, 'utf8');
|
|
@@ -90,3 +97,72 @@ export function jobForgeIndexSummary(projectDir = resolveProjectDir()) {
|
|
|
90
97
|
configHash: index.configHash,
|
|
91
98
|
};
|
|
92
99
|
}
|
|
100
|
+
|
|
101
|
+
function canonicalizeJobForgeIndex(index, projectDir) {
|
|
102
|
+
const records = (index.records || []).map((record) => canonicalizeJobForgeIndexRecord(record, projectDir));
|
|
103
|
+
records.sort(compareRecords);
|
|
104
|
+
return {
|
|
105
|
+
...index,
|
|
106
|
+
records,
|
|
107
|
+
stats: {
|
|
108
|
+
...(index.stats || {}),
|
|
109
|
+
records: records.length,
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function canonicalizeJobForgeIndexRecord(record, projectDir) {
|
|
115
|
+
const key = canonicalIndexKey(record, projectDir);
|
|
116
|
+
if (key === record.key) return record;
|
|
117
|
+
const updated = { ...record, key };
|
|
118
|
+
return { ...updated, id: recordId(updated) };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function canonicalIndexKey(record, projectDir) {
|
|
122
|
+
if (isCompanyRoleRecord(record)) {
|
|
123
|
+
const { company, role } = companyRoleFields(record);
|
|
124
|
+
if (company && role) return safeCompanyRoleKey(company, role, projectDir);
|
|
125
|
+
}
|
|
126
|
+
if (isUrlRecord(record)) {
|
|
127
|
+
const url = record.fields?.url;
|
|
128
|
+
if (url) return safeUrlKey(url, projectDir);
|
|
129
|
+
}
|
|
130
|
+
return record.key;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function isCompanyRoleRecord(record) {
|
|
134
|
+
return record.kind === 'jobforge.application' || record.kind === 'jobforge.tracker-addition';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function companyRoleFields(record) {
|
|
138
|
+
const fields = record.fields || {};
|
|
139
|
+
return {
|
|
140
|
+
company: fields.company || fields.Company,
|
|
141
|
+
role: fields.role || fields.Role,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function isUrlRecord(record) {
|
|
146
|
+
return record.kind === 'jobforge.report.url' || record.kind === 'jobforge.pipeline.url' || record.kind === 'jobforge.scan.url';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function safeCompanyRoleKey(company, role, projectDir) {
|
|
150
|
+
try {
|
|
151
|
+
return jobForgeCompanyRoleKey(company, role, projectDir);
|
|
152
|
+
} catch {
|
|
153
|
+
return legacyCompanyRoleKey(company, role);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function safeUrlKey(url, projectDir) {
|
|
158
|
+
try {
|
|
159
|
+
return jobForgeUrlKey(url, projectDir);
|
|
160
|
+
} catch {
|
|
161
|
+
return legacyUrlKey(url);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function compareRecords(a, b) {
|
|
166
|
+
return `${a.kind}\0${a.key}\0${a.source?.path || ''}\0${a.source?.line || ''}\0${a.id}`
|
|
167
|
+
.localeCompare(`${b.kind}\0${b.key}\0${b.source?.path || ''}\0${b.source?.line || ''}\0${b.id}`);
|
|
168
|
+
}
|
package/lib/jobforge-ledger.mjs
CHANGED
|
@@ -8,6 +8,16 @@ import {
|
|
|
8
8
|
readLedger,
|
|
9
9
|
verifyLedger,
|
|
10
10
|
} from '@razroo/iso-ledger';
|
|
11
|
+
import {
|
|
12
|
+
jobForgeApplicationSubject,
|
|
13
|
+
jobForgeCompanyRoleKey,
|
|
14
|
+
jobForgeUrlKey,
|
|
15
|
+
legacyCompanyRoleKey,
|
|
16
|
+
legacySlugPart,
|
|
17
|
+
legacyUrlKey,
|
|
18
|
+
} from './jobforge-canon.mjs';
|
|
19
|
+
|
|
20
|
+
export { legacyCompanyRoleKey, legacyUrlKey };
|
|
11
21
|
|
|
12
22
|
export const LEDGER_DIR = '.jobforge-ledger';
|
|
13
23
|
export const LEDGER_FILE = 'events.jsonl';
|
|
@@ -89,7 +99,7 @@ export function recordTrackerMergeResult(addition, options = {}) {
|
|
|
89
99
|
|
|
90
100
|
export function buildApplicationEvent(type, app, options = {}) {
|
|
91
101
|
const projectDir = resolveProjectDir(options.projectDir);
|
|
92
|
-
const key = companyRoleKey(app.company, app.role);
|
|
102
|
+
const key = companyRoleKey(app.company, app.role, projectDir);
|
|
93
103
|
const sourceFile = options.sourceFile ? relativePath(projectDir, options.sourceFile) : '';
|
|
94
104
|
const idempotencyParts = [
|
|
95
105
|
options.idempotencyPrefix || type,
|
|
@@ -104,7 +114,7 @@ export function buildApplicationEvent(type, app, options = {}) {
|
|
|
104
114
|
return {
|
|
105
115
|
type,
|
|
106
116
|
key,
|
|
107
|
-
subject: applicationSubject(app.company, app.role),
|
|
117
|
+
subject: applicationSubject(app.company, app.role, projectDir),
|
|
108
118
|
idempotencyKey: idempotencyParts.join(':'),
|
|
109
119
|
data: compactObject({
|
|
110
120
|
num: numberOrString(app.num),
|
|
@@ -128,7 +138,7 @@ export function buildApplicationEvent(type, app, options = {}) {
|
|
|
128
138
|
|
|
129
139
|
export function buildPipelineEvent(item, options = {}) {
|
|
130
140
|
const projectDir = resolveProjectDir(options.projectDir);
|
|
131
|
-
const key = item.url ? urlKey(item.url) : `pipeline:${item.lineNumber || 'unknown'}`;
|
|
141
|
+
const key = item.url ? urlKey(item.url, projectDir) : `pipeline:${item.lineNumber || 'unknown'}`;
|
|
132
142
|
const sourceFile = options.sourceFile ? relativePath(projectDir, options.sourceFile) : 'data/pipeline.md';
|
|
133
143
|
const state = item.checked ? 'processed' : 'pending';
|
|
134
144
|
|
|
@@ -154,25 +164,33 @@ export function buildPipelineEvent(item, options = {}) {
|
|
|
154
164
|
};
|
|
155
165
|
}
|
|
156
166
|
|
|
157
|
-
export function companyRoleKey(company, role) {
|
|
158
|
-
|
|
167
|
+
export function companyRoleKey(company, role, projectDir = resolveProjectDir()) {
|
|
168
|
+
try {
|
|
169
|
+
return jobForgeCompanyRoleKey(company, role, projectDir);
|
|
170
|
+
} catch {
|
|
171
|
+
return legacyCompanyRoleKey(company, role);
|
|
172
|
+
}
|
|
159
173
|
}
|
|
160
174
|
|
|
161
|
-
export function applicationSubject(company, role) {
|
|
162
|
-
|
|
175
|
+
export function applicationSubject(company, role, projectDir = resolveProjectDir()) {
|
|
176
|
+
try {
|
|
177
|
+
return jobForgeApplicationSubject(company, role, projectDir);
|
|
178
|
+
} catch {
|
|
179
|
+
const key = legacyCompanyRoleKey(company, role).slice('company-role:'.length);
|
|
180
|
+
return `application:${key}`;
|
|
181
|
+
}
|
|
163
182
|
}
|
|
164
183
|
|
|
165
|
-
export function urlKey(url) {
|
|
166
|
-
|
|
184
|
+
export function urlKey(url, projectDir = resolveProjectDir()) {
|
|
185
|
+
try {
|
|
186
|
+
return jobForgeUrlKey(url, projectDir);
|
|
187
|
+
} catch {
|
|
188
|
+
return legacyUrlKey(url);
|
|
189
|
+
}
|
|
167
190
|
}
|
|
168
191
|
|
|
169
192
|
export function slugPart(value) {
|
|
170
|
-
|
|
171
|
-
.toLowerCase()
|
|
172
|
-
.replace(/&/g, ' and ')
|
|
173
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
174
|
-
.replace(/^-+|-+$/g, '');
|
|
175
|
-
return slug || 'unknown';
|
|
193
|
+
return legacySlugPart(value);
|
|
176
194
|
}
|
|
177
195
|
|
|
178
196
|
function relativePath(projectDir, value) {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import {
|
|
4
|
+
loadPreflightConfig,
|
|
5
|
+
parseJson,
|
|
6
|
+
planPreflight,
|
|
7
|
+
} from '@razroo/iso-preflight';
|
|
8
|
+
|
|
9
|
+
export const PREFLIGHT_CONFIG_FILE = 'templates/preflight.json';
|
|
10
|
+
export const PREFLIGHT_WORKFLOW = 'jobforge.apply';
|
|
11
|
+
|
|
12
|
+
export function resolveProjectDir(projectDir = process.env.JOB_FORGE_PROJECT || process.cwd()) {
|
|
13
|
+
return projectDir;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function jobForgePreflightConfigPath(projectDir = resolveProjectDir()) {
|
|
17
|
+
return process.env.JOB_FORGE_PREFLIGHT_CONFIG || join(projectDir, PREFLIGHT_CONFIG_FILE);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function readJobForgePreflightConfig(projectDir = resolveProjectDir()) {
|
|
21
|
+
const path = jobForgePreflightConfigPath(projectDir);
|
|
22
|
+
return loadPreflightConfig(parseJson(readFileSync(path, 'utf8'), path));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function planJobForgePreflight(candidateInput, options = {}, projectDir = resolveProjectDir()) {
|
|
26
|
+
return planPreflight(readJobForgePreflightConfig(projectDir), candidateInput, {
|
|
27
|
+
workflow: options.workflow || PREFLIGHT_WORKFLOW,
|
|
28
|
+
});
|
|
29
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "job-forge",
|
|
3
|
-
"version": "2.14.
|
|
3
|
+
"version": "2.14.28",
|
|
4
4
|
"description": "AI-powered job search pipeline built on opencode",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -55,6 +55,13 @@
|
|
|
55
55
|
"index:has": "node bin/job-forge.mjs index:has",
|
|
56
56
|
"index:verify": "node bin/job-forge.mjs index:verify",
|
|
57
57
|
"index:explain": "node bin/job-forge.mjs index:explain",
|
|
58
|
+
"canon:normalize": "node bin/job-forge.mjs canon:normalize",
|
|
59
|
+
"canon:key": "node bin/job-forge.mjs canon:key",
|
|
60
|
+
"canon:compare": "node bin/job-forge.mjs canon:compare",
|
|
61
|
+
"canon:explain": "node bin/job-forge.mjs canon:explain",
|
|
62
|
+
"preflight:plan": "node bin/job-forge.mjs preflight:plan",
|
|
63
|
+
"preflight:check": "node bin/job-forge.mjs preflight:check",
|
|
64
|
+
"preflight:explain": "node bin/job-forge.mjs preflight:explain",
|
|
58
65
|
"migrate:plan": "node bin/job-forge.mjs migrate:plan",
|
|
59
66
|
"migrate:apply": "node bin/job-forge.mjs migrate:apply",
|
|
60
67
|
"migrate:check": "node bin/job-forge.mjs migrate:check",
|
|
@@ -127,6 +134,7 @@
|
|
|
127
134
|
"dependencies": {
|
|
128
135
|
"@razroo/iso-capabilities": "^0.1.0",
|
|
129
136
|
"@razroo/iso-cache": "^0.1.0",
|
|
137
|
+
"@razroo/iso-canon": "^0.1.0",
|
|
130
138
|
"@razroo/iso-context": "^0.1.0",
|
|
131
139
|
"@razroo/iso-contract": "^0.1.0",
|
|
132
140
|
"@razroo/iso-guard": "^0.1.0",
|
|
@@ -134,6 +142,7 @@
|
|
|
134
142
|
"@razroo/iso-ledger": "^0.1.0",
|
|
135
143
|
"@razroo/iso-migrate": "^0.1.0",
|
|
136
144
|
"@razroo/iso-orchestrator": "^0.1.0",
|
|
145
|
+
"@razroo/iso-preflight": "^0.1.0",
|
|
137
146
|
"@razroo/iso-trace": "^0.4.0",
|
|
138
147
|
"playwright": "^1.58.1"
|
|
139
148
|
},
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { relative } from 'path';
|
|
4
|
+
import {
|
|
5
|
+
formatCanonResult,
|
|
6
|
+
formatCompareResult,
|
|
7
|
+
formatConfigSummary,
|
|
8
|
+
} from '@razroo/iso-canon';
|
|
9
|
+
import { PROJECT_DIR } from '../tracker-lib.mjs';
|
|
10
|
+
import {
|
|
11
|
+
canonicalizeJobForgeEntity,
|
|
12
|
+
compareJobForgeCanon,
|
|
13
|
+
jobForgeCanonConfigPath,
|
|
14
|
+
jobForgeCanonProfile,
|
|
15
|
+
} from '../lib/jobforge-canon.mjs';
|
|
16
|
+
|
|
17
|
+
const USAGE = `job-forge canon - deterministic identity keys for JobForge
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
job-forge canon:normalize <url|company|role> <value> [--json]
|
|
21
|
+
job-forge canon:normalize company-role --company <name> --role <title> [--json]
|
|
22
|
+
job-forge canon:key <url|company|role> <value> [--json]
|
|
23
|
+
job-forge canon:key company-role --company <name> --role <title> [--json]
|
|
24
|
+
job-forge canon:compare <url|company|role> <left> <right> [--json]
|
|
25
|
+
job-forge canon:compare company-role --left-company <name> --left-role <title> --right-company <name> --right-role <title> [--json]
|
|
26
|
+
job-forge canon:explain [--json]
|
|
27
|
+
job-forge canon:path
|
|
28
|
+
|
|
29
|
+
The policy is templates/canon.json. These commands are local, model-free, and
|
|
30
|
+
use the same keys that JobForge ledger/cache helpers use internally.`;
|
|
31
|
+
|
|
32
|
+
const [cmd = 'help', ...rawArgs] = process.argv.slice(2);
|
|
33
|
+
const opts = parseArgs(rawArgs);
|
|
34
|
+
|
|
35
|
+
if (opts.help || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
36
|
+
console.log(USAGE);
|
|
37
|
+
process.exit(0);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
if (cmd === 'path') {
|
|
42
|
+
console.log(jobForgeCanonConfigPath(PROJECT_DIR));
|
|
43
|
+
} else if (cmd === 'normalize') {
|
|
44
|
+
normalize(opts, false);
|
|
45
|
+
} else if (cmd === 'key') {
|
|
46
|
+
normalize(opts, true);
|
|
47
|
+
} else if (cmd === 'compare') {
|
|
48
|
+
compare(opts);
|
|
49
|
+
} else if (cmd === 'explain') {
|
|
50
|
+
explain(opts);
|
|
51
|
+
} else {
|
|
52
|
+
console.error(`unknown canon command "${cmd}"\n`);
|
|
53
|
+
console.error(USAGE);
|
|
54
|
+
process.exit(2);
|
|
55
|
+
}
|
|
56
|
+
} catch (error) {
|
|
57
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseArgs(args) {
|
|
62
|
+
const opts = {
|
|
63
|
+
json: false,
|
|
64
|
+
help: false,
|
|
65
|
+
values: [],
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
for (let i = 0; i < args.length; i++) {
|
|
69
|
+
const arg = args[i];
|
|
70
|
+
if (arg === '--json') {
|
|
71
|
+
opts.json = true;
|
|
72
|
+
} else if (arg === '--company') {
|
|
73
|
+
opts.company = valueAfter(args, ++i, '--company');
|
|
74
|
+
} else if (arg.startsWith('--company=')) {
|
|
75
|
+
opts.company = arg.slice('--company='.length);
|
|
76
|
+
} else if (arg === '--role') {
|
|
77
|
+
opts.role = valueAfter(args, ++i, '--role');
|
|
78
|
+
} else if (arg.startsWith('--role=')) {
|
|
79
|
+
opts.role = arg.slice('--role='.length);
|
|
80
|
+
} else if (arg === '--left-company') {
|
|
81
|
+
opts.leftCompany = valueAfter(args, ++i, '--left-company');
|
|
82
|
+
} else if (arg.startsWith('--left-company=')) {
|
|
83
|
+
opts.leftCompany = arg.slice('--left-company='.length);
|
|
84
|
+
} else if (arg === '--left-role') {
|
|
85
|
+
opts.leftRole = valueAfter(args, ++i, '--left-role');
|
|
86
|
+
} else if (arg.startsWith('--left-role=')) {
|
|
87
|
+
opts.leftRole = arg.slice('--left-role='.length);
|
|
88
|
+
} else if (arg === '--right-company') {
|
|
89
|
+
opts.rightCompany = valueAfter(args, ++i, '--right-company');
|
|
90
|
+
} else if (arg.startsWith('--right-company=')) {
|
|
91
|
+
opts.rightCompany = arg.slice('--right-company='.length);
|
|
92
|
+
} else if (arg === '--right-role') {
|
|
93
|
+
opts.rightRole = valueAfter(args, ++i, '--right-role');
|
|
94
|
+
} else if (arg.startsWith('--right-role=')) {
|
|
95
|
+
opts.rightRole = arg.slice('--right-role='.length);
|
|
96
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
97
|
+
opts.help = true;
|
|
98
|
+
} else if (arg.startsWith('--')) {
|
|
99
|
+
throw new Error(`unknown flag "${arg}"`);
|
|
100
|
+
} else {
|
|
101
|
+
opts.values.push(arg);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return opts;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function normalize(opts, keyOnly) {
|
|
109
|
+
const type = parseType(opts.values.shift(), keyOnly ? 'key' : 'normalize');
|
|
110
|
+
const input = normalizeInput(type, opts);
|
|
111
|
+
const result = canonicalizeJobForgeEntity(type, input, PROJECT_DIR);
|
|
112
|
+
if (opts.json) {
|
|
113
|
+
console.log(JSON.stringify(result, null, 2));
|
|
114
|
+
} else if (keyOnly) {
|
|
115
|
+
console.log(result.key);
|
|
116
|
+
} else {
|
|
117
|
+
console.log(formatCanonResult(result));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function compare(opts) {
|
|
122
|
+
const type = parseType(opts.values.shift(), 'compare');
|
|
123
|
+
const [left, right] = compareInputs(type, opts);
|
|
124
|
+
const result = compareJobForgeCanon(type, left, right, PROJECT_DIR);
|
|
125
|
+
if (opts.json) {
|
|
126
|
+
console.log(JSON.stringify(result, null, 2));
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
console.log(formatCompareResult(result));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function explain(opts) {
|
|
133
|
+
const profile = jobForgeCanonProfile(PROJECT_DIR);
|
|
134
|
+
if (opts.json) {
|
|
135
|
+
console.log(JSON.stringify(profile, null, 2));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
console.log(`config: ${relativePath(jobForgeCanonConfigPath(PROJECT_DIR))}`);
|
|
139
|
+
console.log(formatConfigSummary({ version: 1, profiles: [profile] }));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function normalizeInput(type, opts) {
|
|
143
|
+
if (type === 'company-role') {
|
|
144
|
+
if (!opts.company || !opts.role) throw new Error('company-role requires --company and --role');
|
|
145
|
+
return { company: opts.company, role: opts.role };
|
|
146
|
+
}
|
|
147
|
+
if (opts.values.length !== 1) throw new Error(`${type}: provide exactly one value; quote values containing spaces`);
|
|
148
|
+
return opts.values[0];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function compareInputs(type, opts) {
|
|
152
|
+
if (type === 'company-role') {
|
|
153
|
+
if (!opts.leftCompany || !opts.leftRole || !opts.rightCompany || !opts.rightRole) {
|
|
154
|
+
throw new Error('company-role compare requires --left-company, --left-role, --right-company, and --right-role');
|
|
155
|
+
}
|
|
156
|
+
return [
|
|
157
|
+
{ company: opts.leftCompany, role: opts.leftRole },
|
|
158
|
+
{ company: opts.rightCompany, role: opts.rightRole },
|
|
159
|
+
];
|
|
160
|
+
}
|
|
161
|
+
if (opts.values.length !== 2) throw new Error(`${type}: provide exactly two values; quote values containing spaces`);
|
|
162
|
+
return [opts.values[0], opts.values[1]];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function parseType(value, command) {
|
|
166
|
+
if (value === 'url' || value === 'company' || value === 'role' || value === 'company-role') return value;
|
|
167
|
+
throw new Error(`${command}: expected type url, company, role, or company-role`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function valueAfter(values, index, flag) {
|
|
171
|
+
const value = values[index];
|
|
172
|
+
if (!value || value.startsWith('--')) throw new Error(`${flag} requires a value`);
|
|
173
|
+
return value;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function relativePath(path) {
|
|
177
|
+
return relative(PROJECT_DIR, path) || '.';
|
|
178
|
+
}
|
package/scripts/ledger.mjs
CHANGED
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
jobForgeLedgerPath,
|
|
17
17
|
jobForgeLedgerSummary,
|
|
18
18
|
ledgerExists,
|
|
19
|
+
legacyCompanyRoleKey,
|
|
20
|
+
legacyUrlKey,
|
|
19
21
|
readJobForgeLedger,
|
|
20
22
|
urlKey,
|
|
21
23
|
verifyJobForgeLedger,
|
|
@@ -201,8 +203,9 @@ function verify(opts) {
|
|
|
201
203
|
}
|
|
202
204
|
|
|
203
205
|
function has(opts) {
|
|
204
|
-
const filters =
|
|
205
|
-
const
|
|
206
|
+
const filters = queryFilterCandidates(opts);
|
|
207
|
+
const ledger = readJobForgeLedger(PROJECT_DIR);
|
|
208
|
+
const events = uniqueEvents(filters.flatMap((filter) => queryEvents(ledger, filter)));
|
|
206
209
|
if (opts.json) {
|
|
207
210
|
console.log(JSON.stringify({ match: events.length > 0, count: events.length, filters }, null, 2));
|
|
208
211
|
} else if (events.length > 0) {
|
|
@@ -237,6 +240,28 @@ function queryFilters(opts) {
|
|
|
237
240
|
return filters;
|
|
238
241
|
}
|
|
239
242
|
|
|
243
|
+
function queryFilterCandidates(opts) {
|
|
244
|
+
const primary = queryFilters(opts);
|
|
245
|
+
const candidates = [primary];
|
|
246
|
+
const legacy = { ...primary };
|
|
247
|
+
if (opts.url) legacy.key = legacyUrlKey(opts.url);
|
|
248
|
+
if (opts.company || opts.role) legacy.key = legacyCompanyRoleKey(opts.company, opts.role);
|
|
249
|
+
if (legacy.key && legacy.key !== primary.key) candidates.push(legacy);
|
|
250
|
+
return candidates;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function uniqueEvents(events) {
|
|
254
|
+
const seen = new Set();
|
|
255
|
+
const out = [];
|
|
256
|
+
for (const event of events) {
|
|
257
|
+
const id = event.id || event.idempotencyKey || JSON.stringify(event);
|
|
258
|
+
if (seen.has(id)) continue;
|
|
259
|
+
seen.add(id);
|
|
260
|
+
out.push(event);
|
|
261
|
+
}
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
264
|
+
|
|
240
265
|
function collectProjectEvents() {
|
|
241
266
|
const events = [];
|
|
242
267
|
const { entries } = readAllEntries();
|