@structor-dev/cli 0.1.0 → 0.2.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 (64) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +131 -21
  3. package/ROADMAP.md +38 -0
  4. package/SECURITY.md +33 -0
  5. package/bin/structor.mjs +553 -29
  6. package/contrib/self-harness/files/README.md +32 -0
  7. package/contrib/self-harness/files/ai/AGENTS.md +35 -0
  8. package/contrib/self-harness/files/ai/ARCHITECTURE.md +38 -0
  9. package/contrib/self-harness/files/ai/HUB.md +59 -0
  10. package/contrib/self-harness/files/ai/PRODUCT.md +36 -0
  11. package/contrib/self-harness/files/ai/QUALITY.md +31 -0
  12. package/contrib/self-harness/files/ai/context.md +38 -0
  13. package/contrib/self-harness/files/scripts/check-workspace.mjs +72 -0
  14. package/contrib/self-harness/harness.config.json +37 -0
  15. package/docs/CONTRIBUTOR-SETUP.md +45 -0
  16. package/docs/INIT.md +55 -2
  17. package/docs/public-launch.md +150 -0
  18. package/examples/anthropic-only/harness.config.json +26 -0
  19. package/examples/frontend-backend/harness.config.json +8 -8
  20. package/examples/generated-harness-tree.md +432 -0
  21. package/examples/openai-and-anthropic/harness.config.json +7 -7
  22. package/examples/single-repo/harness.config.json +7 -7
  23. package/harness.config.example.json +1 -1
  24. package/package.json +12 -4
  25. package/schemas/contract-manifest.schema.json +0 -1
  26. package/schemas/harness-config.schema.json +5 -2
  27. package/scripts/check-config.mjs +20 -31
  28. package/scripts/check-examples.mjs +146 -0
  29. package/scripts/check-public-hygiene.mjs +249 -0
  30. package/scripts/check-schemas.mjs +42 -0
  31. package/scripts/check-template-files.mjs +15 -98
  32. package/scripts/generated-harness-contract.mjs +416 -0
  33. package/scripts/init-harness.mjs +227 -139
  34. package/scripts/lib.mjs +462 -12
  35. package/scripts/rendered-config.mjs +109 -0
  36. package/scripts/setup-contributor.mjs +125 -0
  37. package/scripts/smoke-template.mjs +260 -73
  38. package/template/AGENTS.md.tpl +4 -2
  39. package/template/README.md.tpl +5 -0
  40. package/template/ai/CODEX-HOOKS.md.tpl +1 -1
  41. package/template/ai/HARNESS-ENGINEERING.md.tpl +5 -2
  42. package/template/ai/HARNESS.md.tpl +4 -1
  43. package/template/ai/contracts/codex-hooks.contract.json.tpl +58 -1
  44. package/template/ai/contracts/codex-hooks.md.tpl +6 -0
  45. package/template/ai/contracts/release-flow.md.tpl +1 -1
  46. package/template/ai/templates/fixtures/issues/valid-ready.md.tpl +3 -1
  47. package/template/ai/templates/issue-template.md.tpl +3 -1
  48. package/template/ai/workspace/LOCAL-STACK.md.tpl +1 -1
  49. package/template/ai/workspace/SYSTEM-MAP.md.tpl +2 -2
  50. package/template/consumer/AGENTS.md.tpl +4 -4
  51. package/template/consumer/CLAUDE.md.tpl +4 -4
  52. package/template/scripts/bootstrap-workspace.mjs.tpl +11 -25
  53. package/template/scripts/check-claude-compatibility.mjs.tpl +62 -9
  54. package/template/scripts/check-codex-hooks.mjs.tpl +262 -20
  55. package/template/scripts/check-template-governance.mjs.tpl +2 -114
  56. package/template/scripts/check-workspace.mjs.tpl +27 -103
  57. package/template/scripts/check-worktree-bootstrap-fixtures.mjs.tpl +12 -0
  58. package/template/scripts/generate-html-views.mjs.tpl +357 -56
  59. package/template/scripts/generated-harness-contract.mjs.tpl +1 -0
  60. package/template/scripts/hooks/lib/codex-hooks-core.mjs.tpl +14 -3
  61. package/template/scripts/lib/path-safety.mjs.tpl +87 -0
  62. package/template/scripts/lib/worktree-bootstrap.mjs.tpl +16 -13
  63. package/template/scripts/validate-governance.mjs.tpl +52 -36
  64. package/schemas/task-brief.schema.json +0 -37
package/scripts/lib.mjs CHANGED
@@ -1,10 +1,22 @@
1
- import { access, readdir, readFile, stat } from "node:fs/promises";
1
+ import { access, lstat, readdir, readFile, realpath, stat } from "node:fs/promises";
2
2
  import { constants as fsConstants } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
 
6
6
  export const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
7
7
 
8
+ export const consumerRepoSignals = [
9
+ ".git",
10
+ "package.json",
11
+ "pyproject.toml",
12
+ "go.mod",
13
+ "Cargo.toml",
14
+ "pom.xml",
15
+ "build.gradle",
16
+ "Gemfile",
17
+ "composer.json",
18
+ ];
19
+
8
20
  export async function exists(filePath) {
9
21
  try {
10
22
  await access(filePath, fsConstants.F_OK);
@@ -56,7 +68,200 @@ export function isSameOrInsidePath(candidate, root) {
56
68
  return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
57
69
  }
58
70
 
59
- export function assertSafeOutputRoot({
71
+ export function isAbsolutePathString(candidate) {
72
+ return (
73
+ path.isAbsolute(candidate) ||
74
+ candidate.startsWith("/") ||
75
+ candidate.startsWith("\\") ||
76
+ /^[A-Za-z]:/.test(candidate)
77
+ );
78
+ }
79
+
80
+ export function pathHasTraversal(candidate) {
81
+ return candidate.split(/[\\/]+/).includes("..");
82
+ }
83
+
84
+ function pathSegments(candidate) {
85
+ return candidate.split(/[\\/]+/).filter(Boolean);
86
+ }
87
+
88
+ async function lstatIfExists(targetPath) {
89
+ try {
90
+ return await lstat(targetPath);
91
+ } catch (error) {
92
+ if (error?.code === "ENOENT") return null;
93
+ throw error;
94
+ }
95
+ }
96
+
97
+ export function workspaceRootForConfig(configDir, templateRepoRoot = repoRoot) {
98
+ const resolvedConfigDir = path.resolve(configDir);
99
+ const resolvedTemplateRepoRoot = path.resolve(templateRepoRoot);
100
+ return isSameOrInsidePath(resolvedConfigDir, resolvedTemplateRepoRoot)
101
+ ? path.dirname(resolvedTemplateRepoRoot)
102
+ : resolvedConfigDir;
103
+ }
104
+
105
+ export async function canonicalPathForWrite(targetPath) {
106
+ let currentPath = path.resolve(targetPath);
107
+ const missingSegments = [];
108
+
109
+ while (true) {
110
+ if (await exists(currentPath)) {
111
+ return path.join(await realpath(currentPath), ...missingSegments);
112
+ }
113
+
114
+ const parentPath = path.dirname(currentPath);
115
+ if (parentPath === currentPath) {
116
+ return path.join(currentPath, ...missingSegments);
117
+ }
118
+
119
+ missingSegments.unshift(path.basename(currentPath));
120
+ currentPath = parentPath;
121
+ }
122
+ }
123
+
124
+ export function assertSafeConsumerPath({
125
+ consumerName,
126
+ consumerPath,
127
+ workspaceRoot,
128
+ outputRoot = null,
129
+ repoRoot: templateRepoRoot = repoRoot,
130
+ allowTemplateRepoConsumer = false,
131
+ }) {
132
+ const label = `Consumer path for ${consumerName}`;
133
+ const rejectedPath = path.resolve(workspaceRoot, consumerPath);
134
+ const segments = pathSegments(consumerPath);
135
+
136
+ if (isAbsolutePathString(consumerPath)) {
137
+ throw new Error(`${label} is unsafe: absolute consumer paths are not allowed.`);
138
+ }
139
+ if (pathHasTraversal(consumerPath)) {
140
+ throw new Error(`${label} is unsafe: relative traversal is not allowed.`);
141
+ }
142
+ if (segments.filter((segment) => segment !== ".").length === 0) {
143
+ throw new Error(`${label} is unsafe: path must name a consumer repository folder, not the workspace root.`);
144
+ }
145
+ if (segments.includes(".git") || pathContainsSegment(rejectedPath, ".git")) {
146
+ throw new Error(`${label} is unsafe: consumer paths must not contain a .git path segment.`);
147
+ }
148
+ if (!isSameOrInsidePath(rejectedPath, workspaceRoot)) {
149
+ throw new Error(`${label} is unsafe: path must stay inside the workspace ${workspaceRoot}.`);
150
+ }
151
+ if (path.resolve(rejectedPath) === path.resolve(workspaceRoot)) {
152
+ throw new Error(`${label} is unsafe: path must not equal the workspace root ${workspaceRoot}.`);
153
+ }
154
+ if (!allowTemplateRepoConsumer && isSameOrInsidePath(rejectedPath, templateRepoRoot)) {
155
+ throw new Error(`${label} is unsafe: path must not equal or be inside the Structor template repo ${templateRepoRoot}.`);
156
+ }
157
+ if (outputRoot && isSameOrInsidePath(rejectedPath, outputRoot)) {
158
+ throw new Error(`${label} is unsafe: path must not equal or be inside the generated harness output ${outputRoot}.`);
159
+ }
160
+
161
+ return rejectedPath;
162
+ }
163
+
164
+ export async function hasConsumerRepositorySignal(consumerRoot) {
165
+ for (const signal of consumerRepoSignals) {
166
+ if (await exists(path.join(consumerRoot, signal))) return true;
167
+ }
168
+ return false;
169
+ }
170
+
171
+ export async function assertConfirmedConsumerRepository({
172
+ consumerName,
173
+ consumerRoot,
174
+ workspaceRoot,
175
+ outputRoot = null,
176
+ repoRoot: templateRepoRoot = repoRoot,
177
+ allowTemplateRepoConsumer = false,
178
+ }) {
179
+ const label = `Consumer path for ${consumerName}`;
180
+ const info = await lstatIfExists(consumerRoot);
181
+ if (info === null) {
182
+ throw new Error(`Consumer repo path for ${consumerName} does not exist: ${consumerRoot}`);
183
+ }
184
+ if (info.isSymbolicLink()) {
185
+ throw new Error(`${label} is unsafe: symlinked consumer paths are not allowed: ${consumerRoot}.`);
186
+ }
187
+ if (!info.isDirectory()) {
188
+ throw new Error(`${label} is not a directory: ${consumerRoot}.`);
189
+ }
190
+
191
+ const canonicalWorkspaceRoot = await canonicalPathForWrite(workspaceRoot);
192
+ const canonicalConsumerRoot = await canonicalPathForWrite(consumerRoot);
193
+ if (!isSameOrInsidePath(canonicalConsumerRoot, canonicalWorkspaceRoot)) {
194
+ throw new Error(
195
+ `${label} is unsafe: resolved path escapes workspace ${canonicalWorkspaceRoot}: ${canonicalConsumerRoot}.`,
196
+ );
197
+ }
198
+
199
+ const canonicalTemplateRepoRoot = await canonicalPathForWrite(templateRepoRoot);
200
+ if (!allowTemplateRepoConsumer && isSameOrInsidePath(canonicalConsumerRoot, canonicalTemplateRepoRoot)) {
201
+ throw new Error(
202
+ `${label} is unsafe: resolved path must not equal or be inside the Structor template repo ${canonicalTemplateRepoRoot}.`,
203
+ );
204
+ }
205
+
206
+ if (outputRoot) {
207
+ const canonicalOutputRoot = await canonicalPathForWrite(outputRoot);
208
+ if (isSameOrInsidePath(canonicalConsumerRoot, canonicalOutputRoot)) {
209
+ throw new Error(
210
+ `${label} is unsafe: resolved path must not equal or be inside the generated harness output ${canonicalOutputRoot}.`,
211
+ );
212
+ }
213
+ }
214
+
215
+ if (!(await hasConsumerRepositorySignal(canonicalConsumerRoot))) {
216
+ throw new Error(
217
+ `${label} is not a confirmed consumer repository: ${consumerRoot} (expected one of ${consumerRepoSignals.join(", ")}).`,
218
+ );
219
+ }
220
+
221
+ return canonicalConsumerRoot;
222
+ }
223
+
224
+ async function firstSymlinkUnderRoot(targetPath, rootPath) {
225
+ const resolvedTarget = path.resolve(targetPath);
226
+ const resolvedRoot = path.resolve(rootPath);
227
+ if (!isSameOrInsidePath(resolvedTarget, resolvedRoot)) return null;
228
+
229
+ const relative = path.relative(resolvedRoot, resolvedTarget);
230
+ if (relative === "") return null;
231
+
232
+ let currentPath = resolvedRoot;
233
+ for (const segment of relative.split(path.sep)) {
234
+ currentPath = path.join(currentPath, segment);
235
+ const info = await lstatIfExists(currentPath);
236
+ if (info === null) return null;
237
+ if (info.isSymbolicLink()) return currentPath;
238
+ }
239
+
240
+ return null;
241
+ }
242
+
243
+ export async function assertSafeWriteTarget({ targetPath, rootPath, label = "Write target" }) {
244
+ const resolvedTarget = path.resolve(targetPath);
245
+ const resolvedRoot = path.resolve(rootPath);
246
+ if (!isSameOrInsidePath(resolvedTarget, resolvedRoot)) {
247
+ throw new Error(`${label} is unsafe: target ${resolvedTarget} must stay inside ${resolvedRoot}.`);
248
+ }
249
+
250
+ const symlinkPath = await firstSymlinkUnderRoot(resolvedTarget, resolvedRoot);
251
+ if (symlinkPath !== null) {
252
+ throw new Error(`${label} is unsafe: symlinked write targets are not allowed (${symlinkPath}).`);
253
+ }
254
+
255
+ const canonicalRoot = await canonicalPathForWrite(resolvedRoot);
256
+ const canonicalTarget = await canonicalPathForWrite(resolvedTarget);
257
+ if (!isSameOrInsidePath(canonicalTarget, canonicalRoot)) {
258
+ throw new Error(`${label} is unsafe: resolved target escapes ${canonicalRoot}: ${canonicalTarget}.`);
259
+ }
260
+
261
+ return canonicalTarget;
262
+ }
263
+
264
+ export async function assertSafeOutputRoot({
60
265
  outputPath,
61
266
  outputRoot,
62
267
  repoRoot: templateRepoRoot,
@@ -68,22 +273,190 @@ export function assertSafeOutputRoot({
68
273
  if (path.isAbsolute(outputPath) && !allowAbsoluteOutput) {
69
274
  throw new Error(`Unsafe output path ${rejectedPath}: absolute output paths require --allow-absolute-output.`);
70
275
  }
71
- if (isSameOrInsidePath(outputRoot, templateRepoRoot)) {
72
- throw new Error(`Unsafe output path ${rejectedPath}: output must not equal or be inside the template repo ${templateRepoRoot}.`);
276
+ if (pathContainsSegment(rejectedPath, ".git")) {
277
+ throw new Error(`Unsafe output path ${rejectedPath}: output path must not contain a .git path segment.`);
73
278
  }
74
- if (path.resolve(outputRoot) === path.resolve(workspaceRoot)) {
75
- throw new Error(`Unsafe output path ${rejectedPath}: output must not equal the workspace root ${workspaceRoot}.`);
279
+
280
+ const symlinkPath = await firstSymlinkUnderRoot(outputRoot, workspaceRoot);
281
+ if (symlinkPath !== null) {
282
+ throw new Error(`Unsafe output path ${rejectedPath}: output path must not use symlinked output directories (${symlinkPath}).`);
283
+ }
284
+
285
+ const outputInfo = await lstatIfExists(outputRoot);
286
+ if (outputInfo?.isSymbolicLink()) {
287
+ throw new Error(`Unsafe output path ${rejectedPath}: output path must not use symlinked output directories (${rejectedPath}).`);
288
+ }
289
+
290
+ const canonicalOutputRoot = await canonicalPathForWrite(outputRoot);
291
+ const canonicalTemplateRepoRoot = await canonicalPathForWrite(templateRepoRoot);
292
+ const canonicalWorkspaceRoot = await canonicalPathForWrite(workspaceRoot);
293
+
294
+ if (!path.isAbsolute(outputPath) && !isSameOrInsidePath(canonicalOutputRoot, canonicalWorkspaceRoot)) {
295
+ throw new Error(
296
+ `Unsafe output path ${rejectedPath}: relative output paths must remain inside the workspace boundary ${canonicalWorkspaceRoot}.`,
297
+ );
298
+ }
299
+ if (isSameOrInsidePath(canonicalOutputRoot, canonicalTemplateRepoRoot)) {
300
+ throw new Error(`Unsafe output path ${rejectedPath}: output must not equal or be inside the template repo ${canonicalTemplateRepoRoot}.`);
301
+ }
302
+ if (canonicalOutputRoot === canonicalWorkspaceRoot) {
303
+ throw new Error(`Unsafe output path ${rejectedPath}: output must not equal the workspace root ${canonicalWorkspaceRoot}.`);
76
304
  }
77
305
  for (const consumerRoot of consumerRepos) {
78
- if (isSameOrInsidePath(outputRoot, consumerRoot)) {
306
+ const canonicalConsumerRoot = await canonicalPathForWrite(consumerRoot);
307
+ if (isSameOrInsidePath(canonicalOutputRoot, canonicalConsumerRoot)) {
79
308
  throw new Error(
80
- `Unsafe output path ${rejectedPath}: output must not equal or be inside configured consumer repo ${consumerRoot}.`,
309
+ `Unsafe output path ${rejectedPath}: output must not equal or be inside configured consumer repo ${canonicalConsumerRoot}.`,
81
310
  );
82
311
  }
83
312
  }
84
- if (pathContainsSegment(outputRoot, ".git")) {
313
+ if (pathContainsSegment(canonicalOutputRoot, ".git")) {
85
314
  throw new Error(`Unsafe output path ${rejectedPath}: output path must not contain a .git path segment.`);
86
315
  }
316
+
317
+ return canonicalOutputRoot;
318
+ }
319
+
320
+ export class ConfigResolutionError extends Error {
321
+ constructor(errors) {
322
+ super(errors.join("\n"));
323
+ this.name = "ConfigResolutionError";
324
+ this.errors = errors;
325
+ }
326
+ }
327
+
328
+ export function resolveClientSupport(config) {
329
+ return {
330
+ codexHooks: config.models.openai && (config.clientSupport?.codex?.hooks ?? true),
331
+ claudeRules: config.models.anthropic && (config.clientSupport?.claude?.rules ?? true),
332
+ claudeHooks: config.models.anthropic && (config.clientSupport?.claude?.hooks ?? false),
333
+ claudeSkills: config.models.anthropic && (config.clientSupport?.claude?.skills ?? false),
334
+ };
335
+ }
336
+
337
+ function configResolutionMessage(label, error) {
338
+ const message = error instanceof Error ? error.message : String(error);
339
+ return `${label}: ${message}`;
340
+ }
341
+
342
+ export async function resolveHarnessConfig(config, {
343
+ label = "harness config",
344
+ configPath = null,
345
+ configDir = configPath ? path.dirname(path.resolve(configPath)) : process.cwd(),
346
+ outputPath = config?.output?.path,
347
+ repoRoot: templateRepoRoot = repoRoot,
348
+ allowAbsoluteOutput = false,
349
+ requireExistingConsumers = false,
350
+ allowTemplateRepoConsumer = false,
351
+ } = {}) {
352
+ const errors = await validateConfigShape(config, label);
353
+ if (errors.length > 0) {
354
+ throw new ConfigResolutionError(errors);
355
+ }
356
+
357
+ const resolvedConfigDir = path.resolve(configDir);
358
+ const workspaceRoot = workspaceRootForConfig(resolvedConfigDir, templateRepoRoot);
359
+ const requestedOutputRoot = path.resolve(resolvedConfigDir, outputPath);
360
+ const consumerRoots = [];
361
+
362
+ for (const consumer of config.consumers) {
363
+ try {
364
+ consumerRoots.push(assertSafeConsumerPath({
365
+ consumerName: consumer.name,
366
+ consumerPath: consumer.path,
367
+ workspaceRoot,
368
+ repoRoot: templateRepoRoot,
369
+ allowTemplateRepoConsumer,
370
+ }));
371
+ } catch (error) {
372
+ errors.push(configResolutionMessage(label, error));
373
+ }
374
+ }
375
+
376
+ let outputRoot = requestedOutputRoot;
377
+ try {
378
+ outputRoot = await assertSafeOutputRoot({
379
+ outputPath,
380
+ outputRoot: requestedOutputRoot,
381
+ repoRoot: templateRepoRoot,
382
+ workspaceRoot,
383
+ consumerRepos: consumerRoots,
384
+ allowAbsoluteOutput,
385
+ });
386
+ } catch (error) {
387
+ errors.push(configResolutionMessage(label, error));
388
+ }
389
+
390
+ if (errors.length > 0) {
391
+ throw new ConfigResolutionError(errors);
392
+ }
393
+
394
+ const canonicalWorkspaceRoot = await canonicalPathForWrite(workspaceRoot);
395
+ const canonicalTemplateRepoRoot = await canonicalPathForWrite(templateRepoRoot);
396
+ const consumers = [];
397
+ for (const consumer of config.consumers) {
398
+ try {
399
+ const consumerRoot = assertSafeConsumerPath({
400
+ consumerName: consumer.name,
401
+ consumerPath: consumer.path,
402
+ workspaceRoot,
403
+ outputRoot,
404
+ repoRoot: templateRepoRoot,
405
+ allowTemplateRepoConsumer,
406
+ });
407
+ const confirmedRoot = requireExistingConsumers
408
+ ? await assertConfirmedConsumerRepository({
409
+ consumerName: consumer.name,
410
+ consumerRoot,
411
+ workspaceRoot,
412
+ outputRoot,
413
+ repoRoot: templateRepoRoot,
414
+ allowTemplateRepoConsumer,
415
+ })
416
+ : null;
417
+ const canonicalConsumerRoot = confirmedRoot ?? await canonicalPathForWrite(consumerRoot);
418
+ if (!confirmedRoot) {
419
+ if (!isSameOrInsidePath(canonicalConsumerRoot, canonicalWorkspaceRoot)) {
420
+ throw new Error(
421
+ `Consumer path for ${consumer.name} is unsafe: resolved path escapes workspace ${canonicalWorkspaceRoot}: ${canonicalConsumerRoot}.`,
422
+ );
423
+ }
424
+ if (!allowTemplateRepoConsumer && isSameOrInsidePath(canonicalConsumerRoot, canonicalTemplateRepoRoot)) {
425
+ throw new Error(
426
+ `Consumer path for ${consumer.name} is unsafe: resolved path must not equal or be inside the Structor template repo ${canonicalTemplateRepoRoot}.`,
427
+ );
428
+ }
429
+ if (isSameOrInsidePath(canonicalConsumerRoot, outputRoot)) {
430
+ throw new Error(
431
+ `Consumer path for ${consumer.name} is unsafe: resolved path must not equal or be inside the generated harness output ${outputRoot}.`,
432
+ );
433
+ }
434
+ }
435
+ consumers.push({
436
+ config: consumer,
437
+ requestedRoot: consumerRoot,
438
+ root: canonicalConsumerRoot,
439
+ confirmedRoot,
440
+ });
441
+ } catch (error) {
442
+ errors.push(configResolutionMessage(label, error));
443
+ }
444
+ }
445
+
446
+ if (errors.length > 0) {
447
+ throw new ConfigResolutionError(errors);
448
+ }
449
+
450
+ return {
451
+ config,
452
+ configDir: resolvedConfigDir,
453
+ workspaceRoot,
454
+ outputPath,
455
+ requestedOutputRoot,
456
+ outputRoot,
457
+ support: resolveClientSupport(config),
458
+ consumers,
459
+ };
87
460
  }
88
461
 
89
462
  export function failIfErrors(title, errors) {
@@ -109,7 +482,63 @@ function typeName(value) {
109
482
  return typeof value;
110
483
  }
111
484
 
112
- function validateJsonSchema(value, schema, label, errors) {
485
+ const SUPPORTED_SCHEMA_KEYWORDS = new Set([
486
+ "$id",
487
+ "$schema",
488
+ "additionalProperties",
489
+ "const",
490
+ "description",
491
+ "enum",
492
+ "items",
493
+ "minItems",
494
+ "minLength",
495
+ "pattern",
496
+ "properties",
497
+ "required",
498
+ "title",
499
+ "type",
500
+ ]);
501
+
502
+ function collectUnsupportedSchemaKeywords(schema, label, errors) {
503
+ if (!isPlainObject(schema)) return;
504
+
505
+ for (const key of Object.keys(schema)) {
506
+ if (!SUPPORTED_SCHEMA_KEYWORDS.has(key)) {
507
+ errors.push(`${label} schema uses unsupported keyword ${key}.`);
508
+ }
509
+ }
510
+
511
+ if (Object.hasOwn(schema, "items") && !isPlainObject(schema.items)) {
512
+ errors.push(`${label}.items must be a schema object; tuple or boolean items are not supported.`);
513
+ } else if (isPlainObject(schema.items)) {
514
+ collectUnsupportedSchemaKeywords(schema.items, `${label}[]`, errors);
515
+ }
516
+
517
+ if (
518
+ Object.hasOwn(schema, "additionalProperties") &&
519
+ typeof schema.additionalProperties !== "boolean"
520
+ ) {
521
+ errors.push(`${label}.additionalProperties must be a boolean; schema-valued additionalProperties is not supported.`);
522
+ }
523
+
524
+ if (Object.hasOwn(schema, "properties") && !isPlainObject(schema.properties)) {
525
+ errors.push(`${label}.properties must be an object of schema objects.`);
526
+ } else if (isPlainObject(schema.properties)) {
527
+ for (const [key, propertySchema] of Object.entries(schema.properties)) {
528
+ if (!isPlainObject(propertySchema)) {
529
+ errors.push(`${label}.${key} schema must be an object.`);
530
+ continue;
531
+ }
532
+ collectUnsupportedSchemaKeywords(propertySchema, `${label}.${key}`, errors);
533
+ }
534
+ }
535
+ }
536
+
537
+ function jsonSchemaValueEquals(actual, expected) {
538
+ return Object.is(actual, expected);
539
+ }
540
+
541
+ function validateJsonSchemaValue(value, schema, label, errors) {
113
542
  const expectedType = schema.type;
114
543
  if (expectedType) {
115
544
  const validType =
@@ -126,6 +555,10 @@ function validateJsonSchema(value, schema, label, errors) {
126
555
  errors.push(`${label} must be ${JSON.stringify(schema.const)}.`);
127
556
  }
128
557
 
558
+ if (Array.isArray(schema.enum) && !schema.enum.some((allowed) => jsonSchemaValueEquals(value, allowed))) {
559
+ errors.push(`${label} must be one of ${schema.enum.map((allowed) => JSON.stringify(allowed)).join(", ")}.`);
560
+ }
561
+
129
562
  if (typeof value === "string") {
130
563
  if (schema.minLength !== undefined && value.length < schema.minLength) {
131
564
  errors.push(`${label} must be at least ${schema.minLength} character(s).`);
@@ -141,7 +574,7 @@ function validateJsonSchema(value, schema, label, errors) {
141
574
  }
142
575
  if (schema.items) {
143
576
  for (const [index, item] of value.entries()) {
144
- validateJsonSchema(item, schema.items, `${label}[${index}]`, errors);
577
+ validateJsonSchemaValue(item, schema.items, `${label}[${index}]`, errors);
145
578
  }
146
579
  }
147
580
  }
@@ -162,12 +595,17 @@ function validateJsonSchema(value, schema, label, errors) {
162
595
  }
163
596
  for (const [key, propertySchema] of Object.entries(properties)) {
164
597
  if (Object.hasOwn(value, key)) {
165
- validateJsonSchema(value[key], propertySchema, `${label}.${key}`, errors);
598
+ validateJsonSchemaValue(value[key], propertySchema, `${label}.${key}`, errors);
166
599
  }
167
600
  }
168
601
  }
169
602
  }
170
603
 
604
+ export function validateJsonSchema(value, schema, label, errors) {
605
+ collectUnsupportedSchemaKeywords(schema, label, errors);
606
+ validateJsonSchemaValue(value, schema, label, errors);
607
+ }
608
+
171
609
  export async function validateConfigShape(config, label) {
172
610
  const errors = [];
173
611
  const schema = await readJson("schemas/harness-config.schema.json");
@@ -180,9 +618,21 @@ export async function validateConfigShape(config, label) {
180
618
  const names = new Set();
181
619
  if (Array.isArray(config.consumers)) {
182
620
  for (const [index, consumer] of config.consumers.entries()) {
621
+ if (!isPlainObject(consumer)) continue;
183
622
  const prefix = `${label}.consumers[${index}]`;
184
623
  if (names.has(consumer.name)) errors.push(`${prefix}.name is duplicated.`);
185
624
  names.add(consumer.name);
625
+ if (typeof consumer.path === "string") {
626
+ if (isAbsolutePathString(consumer.path)) {
627
+ errors.push(`${prefix}.path must be relative to the workspace; absolute paths are not allowed.`);
628
+ }
629
+ if (pathHasTraversal(consumer.path)) {
630
+ errors.push(`${prefix}.path must not contain relative traversal segments.`);
631
+ }
632
+ if (pathSegments(consumer.path).filter((segment) => segment !== ".").length === 0) {
633
+ errors.push(`${prefix}.path must name a consumer repository folder, not the workspace root.`);
634
+ }
635
+ }
186
636
  }
187
637
  }
188
638
 
@@ -0,0 +1,109 @@
1
+ import path from "node:path";
2
+
3
+ const rawSlugPattern = /^[a-z0-9][a-z0-9-]*$/;
4
+
5
+ export function markdownText(value) {
6
+ const normalized = String(value).replace(/\s+/g, " ").trim();
7
+ const escaped = normalized.replace(/[\\`*_{}\[\]<>()#+!|>~]/g, "\\$&");
8
+ return escaped.replace(/^([-+]) /, "\\$1 ").replace(/^(\d+)([.)]) /, "$1\\$2 ");
9
+ }
10
+
11
+ export function markdownCodeSpan(value) {
12
+ const text = String(value)
13
+ .replace(/\r/g, "\\r")
14
+ .replace(/\n/g, "\\n")
15
+ .replace(/\t/g, "\\t");
16
+ const longestBacktickRun = Math.max(0, ...Array.from(text.matchAll(/`+/g), (match) => match[0].length));
17
+ const delimiter = "`".repeat(longestBacktickRun + 1);
18
+ const padding = text.startsWith("`") || text.endsWith("`") || text.startsWith(" ") || text.endsWith(" ") ? " " : "";
19
+ return `${delimiter}${padding}${text}${padding}${delimiter}`;
20
+ }
21
+
22
+ export function javascriptLiteral(value) {
23
+ return JSON.stringify(value);
24
+ }
25
+
26
+ export function jsonLiteral(value) {
27
+ return JSON.stringify(value, null, 2);
28
+ }
29
+
30
+ export function javascriptBoolean(value) {
31
+ return value ? "true" : "false";
32
+ }
33
+
34
+ export function rawSlug(value, label) {
35
+ const text = String(value);
36
+ if (!rawSlugPattern.test(text)) {
37
+ throw new Error(`${label} must be a safe slug before raw template rendering.`);
38
+ }
39
+ return text;
40
+ }
41
+
42
+ export function markdownPathCodeSpan(value) {
43
+ return markdownCodeSpan(String(value).replaceAll(path.sep, "/"));
44
+ }
45
+
46
+ function consumerList(consumers) {
47
+ return consumers.map((consumer) => `- ${markdownCodeSpan(consumer.name)}: ${markdownText(consumer.purpose)}`).join("\n");
48
+ }
49
+
50
+ function validationList(validation) {
51
+ const entries = Object.entries(validation ?? {});
52
+ if (entries.length === 0) return "- No local validation commands documented yet.";
53
+ return entries.map(([name, command]) => `- ${markdownText(name)}: ${markdownCodeSpan(command)}`).join("\n");
54
+ }
55
+
56
+ function consumerNames(consumers) {
57
+ return consumers.map((consumer) => rawSlug(consumer.name, "consumer.name"));
58
+ }
59
+
60
+ function consumerConfig(resolvedConsumers, outputRoot) {
61
+ const generatedWorkspaceRoot = path.dirname(outputRoot);
62
+ return resolvedConsumers.map(({ config: consumer, root: consumerRoot }) => {
63
+ return {
64
+ ...consumer,
65
+ name: rawSlug(consumer.name, "consumer.name"),
66
+ workspacePath: path.relative(generatedWorkspaceRoot, consumerRoot).replaceAll(path.sep, "/") || ".",
67
+ };
68
+ });
69
+ }
70
+
71
+ export function renderedGeneratedScriptHashes(hashes) {
72
+ return jsonLiteral(hashes);
73
+ }
74
+
75
+ export function harnessTemplateValues(config, support, resolvedConsumers, outputRoot) {
76
+ return {
77
+ PROJECT_NAME: markdownText(config.project.name),
78
+ PROJECT_NAME_CODE: markdownCodeSpan(config.project.name),
79
+ PROJECT_NAME_JSON: javascriptLiteral(config.project.name),
80
+ PROJECT_SLUG: rawSlug(config.project.slug, "project.slug"),
81
+ HARNESS_REPO_NAME: rawSlug(config.project.harnessRepoName, "project.harnessRepoName"),
82
+ CONSUMER_REPOS_LIST: consumerList(config.consumers),
83
+ CONSUMER_REPO_NAMES_JSON: javascriptLiteral(consumerNames(config.consumers)),
84
+ CONSUMER_CONFIG_JSON: jsonLiteral(consumerConfig(resolvedConsumers, outputRoot)),
85
+ PRIMARY_CONSUMER_NAME: rawSlug(config.consumers[0].name, "consumer.name"),
86
+ MODEL_OPENAI_ENABLED: javascriptBoolean(config.models.openai),
87
+ MODEL_ANTHROPIC_ENABLED: javascriptBoolean(config.models.anthropic),
88
+ CLIENT_CODEX_HOOKS_ENABLED: javascriptBoolean(support.codexHooks),
89
+ CLIENT_CLAUDE_RULES_ENABLED: javascriptBoolean(support.claudeRules),
90
+ CLIENT_CLAUDE_HOOKS_ENABLED: javascriptBoolean(support.claudeHooks),
91
+ CLIENT_CLAUDE_SKILLS_ENABLED: javascriptBoolean(support.claudeSkills),
92
+ };
93
+ }
94
+
95
+ export function consumerEntrypointValues(config, consumer, harnessRelativePath) {
96
+ const harnessPath = (relativePath) => markdownPathCodeSpan(`${harnessRelativePath}/${relativePath}`);
97
+
98
+ return {
99
+ PROJECT_NAME: markdownText(config.project.name),
100
+ CONSUMER_NAME: markdownText(consumer.name),
101
+ CONSUMER_PURPOSE: markdownText(consumer.purpose),
102
+ CONSUMER_VALIDATION_LIST: validationList(consumer.validation),
103
+ HARNESS_AGENTS_PATH: harnessPath("AGENTS.md"),
104
+ HARNESS_CLAUDE_PATH: harnessPath("CLAUDE.md"),
105
+ HARNESS_AI_AGENTS_PATH: harnessPath("ai/AGENTS.md"),
106
+ HARNESS_AI_HUB_PATH: harnessPath("ai/HUB.md"),
107
+ HARNESS_AI_CONTEXT_PATH: harnessPath("ai/context.md"),
108
+ };
109
+ }