@structor-dev/cli 0.1.0 → 0.2.1
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/CHANGELOG.md +56 -0
- package/README.md +131 -21
- package/ROADMAP.md +38 -0
- package/SECURITY.md +33 -0
- package/bin/structor.mjs +561 -29
- package/contrib/self-harness/files/README.md +32 -0
- package/contrib/self-harness/files/ai/AGENTS.md +35 -0
- package/contrib/self-harness/files/ai/ARCHITECTURE.md +38 -0
- package/contrib/self-harness/files/ai/HUB.md +59 -0
- package/contrib/self-harness/files/ai/PRODUCT.md +36 -0
- package/contrib/self-harness/files/ai/QUALITY.md +31 -0
- package/contrib/self-harness/files/ai/context.md +38 -0
- package/contrib/self-harness/files/scripts/check-workspace.mjs +72 -0
- package/contrib/self-harness/harness.config.json +37 -0
- package/docs/CONTRIBUTOR-SETUP.md +45 -0
- package/docs/INIT.md +55 -2
- package/docs/public-launch.md +150 -0
- package/examples/anthropic-only/harness.config.json +26 -0
- package/examples/frontend-backend/harness.config.json +8 -8
- package/examples/generated-harness-tree.md +432 -0
- package/examples/openai-and-anthropic/harness.config.json +7 -7
- package/examples/single-repo/harness.config.json +7 -7
- package/harness.config.example.json +1 -1
- package/package.json +12 -4
- package/schemas/contract-manifest.schema.json +0 -1
- package/schemas/harness-config.schema.json +5 -2
- package/scripts/check-config.mjs +20 -31
- package/scripts/check-examples.mjs +146 -0
- package/scripts/check-placeholders.mjs +2 -0
- package/scripts/check-public-hygiene.mjs +249 -0
- package/scripts/check-schemas.mjs +42 -0
- package/scripts/check-template-files.mjs +15 -98
- package/scripts/generated-harness-contract.mjs +416 -0
- package/scripts/init-harness.mjs +227 -139
- package/scripts/lib.mjs +462 -12
- package/scripts/rendered-config.mjs +109 -0
- package/scripts/setup-contributor.mjs +125 -0
- package/scripts/smoke-template.mjs +260 -73
- package/template/AGENTS.md.tpl +4 -2
- package/template/README.md.tpl +5 -0
- package/template/ai/CODEX-HOOKS.md.tpl +1 -1
- package/template/ai/HARNESS-ENGINEERING.md.tpl +5 -2
- package/template/ai/HARNESS.md.tpl +4 -1
- package/template/ai/contracts/codex-hooks.contract.json.tpl +58 -1
- package/template/ai/contracts/codex-hooks.md.tpl +6 -0
- package/template/ai/contracts/release-flow.md.tpl +1 -1
- package/template/ai/templates/fixtures/issues/valid-ready.md.tpl +3 -1
- package/template/ai/templates/issue-template.md.tpl +3 -1
- package/template/ai/workspace/LOCAL-STACK.md.tpl +1 -1
- package/template/ai/workspace/SYSTEM-MAP.md.tpl +2 -2
- package/template/consumer/AGENTS.md.tpl +4 -4
- package/template/consumer/CLAUDE.md.tpl +4 -4
- package/template/scripts/bootstrap-workspace.mjs.tpl +11 -25
- package/template/scripts/check-claude-compatibility.mjs.tpl +62 -9
- package/template/scripts/check-codex-hooks.mjs.tpl +262 -20
- package/template/scripts/check-template-governance.mjs.tpl +2 -114
- package/template/scripts/check-workspace.mjs.tpl +27 -103
- package/template/scripts/check-worktree-bootstrap-fixtures.mjs.tpl +12 -0
- package/template/scripts/generate-html-views.mjs.tpl +357 -56
- package/template/scripts/generated-harness-contract.mjs.tpl +1 -0
- package/template/scripts/hooks/lib/codex-hooks-core.mjs.tpl +14 -3
- package/template/scripts/lib/path-safety.mjs.tpl +87 -0
- package/template/scripts/lib/worktree-bootstrap.mjs.tpl +16 -13
- package/template/scripts/validate-governance.mjs.tpl +52 -36
- package/schemas/task-brief.schema.json +0 -37
package/scripts/check-config.mjs
CHANGED
|
@@ -3,13 +3,12 @@
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { readFile } from "node:fs/promises";
|
|
5
5
|
import {
|
|
6
|
-
assertSafeOutputRoot,
|
|
7
6
|
collectFiles,
|
|
8
|
-
|
|
7
|
+
ConfigResolutionError,
|
|
9
8
|
failIfErrors,
|
|
10
9
|
readJson,
|
|
11
10
|
repoRoot,
|
|
12
|
-
|
|
11
|
+
resolveHarnessConfig,
|
|
13
12
|
} from "./lib.mjs";
|
|
14
13
|
|
|
15
14
|
const errors = [];
|
|
@@ -22,49 +21,39 @@ const configFiles = checkingExamples
|
|
|
22
21
|
? ["harness.config.example.json", ...(await collectFiles("examples", (file) => file.endsWith("harness.config.json")))]
|
|
23
22
|
: [path.resolve(args[configArgIndex + 1])];
|
|
24
23
|
|
|
24
|
+
function resolutionErrors(error) {
|
|
25
|
+
if (error instanceof ConfigResolutionError) return error.errors;
|
|
26
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
27
|
+
return message.split("\n").filter(Boolean);
|
|
28
|
+
}
|
|
29
|
+
|
|
25
30
|
for (const configPath of configFiles) {
|
|
26
31
|
const label = checkingExamples ? configPath : path.relative(process.cwd(), configPath);
|
|
27
32
|
const config = checkingExamples
|
|
28
33
|
? await readJson(configPath)
|
|
29
34
|
: JSON.parse(await readFile(configPath, "utf8"));
|
|
30
|
-
errors.push(...(await validateConfigShape(config, label)));
|
|
31
35
|
|
|
32
36
|
if (checkingExamples && path.isAbsolute(config.output?.path ?? "")) {
|
|
33
37
|
errors.push(`${label}: output.path must be relative for examples.`);
|
|
34
38
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
outputRoot,
|
|
46
|
-
repoRoot,
|
|
47
|
-
workspaceRoot: configDir,
|
|
48
|
-
consumerRepos,
|
|
49
|
-
allowAbsoluteOutput,
|
|
50
|
-
});
|
|
51
|
-
} catch (error) {
|
|
52
|
-
errors.push(`${label}: ${error instanceof Error ? error.message : String(error)}`);
|
|
53
|
-
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
await resolveHarnessConfig(config, {
|
|
42
|
+
label,
|
|
43
|
+
configPath: checkingExamples ? path.join(repoRoot, configPath) : configPath,
|
|
44
|
+
allowAbsoluteOutput,
|
|
45
|
+
requireExistingConsumers,
|
|
46
|
+
});
|
|
47
|
+
} catch (error) {
|
|
48
|
+
errors.push(...resolutionErrors(error));
|
|
54
49
|
}
|
|
55
50
|
|
|
56
|
-
if (Array.isArray(config.consumers)) {
|
|
51
|
+
if (checkingExamples && Array.isArray(config.consumers)) {
|
|
57
52
|
for (const consumer of config.consumers) {
|
|
53
|
+
if (typeof consumer?.path !== "string") continue;
|
|
58
54
|
if (checkingExamples && path.isAbsolute(consumer.path)) {
|
|
59
55
|
errors.push(`${label}: consumer path for ${consumer.name} must be relative in checked-in examples.`);
|
|
60
56
|
}
|
|
61
|
-
if (requireExistingConsumers) {
|
|
62
|
-
const configDir = checkingExamples ? path.dirname(path.join(repoRoot, configPath)) : path.dirname(configPath);
|
|
63
|
-
const consumerPath = path.resolve(configDir, consumer.path);
|
|
64
|
-
if (!(await exists(consumerPath))) {
|
|
65
|
-
errors.push(`${label}: consumer path for ${consumer.name} does not exist: ${consumerPath}`);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
57
|
}
|
|
69
58
|
}
|
|
70
59
|
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
artifactTargetPath,
|
|
7
|
+
consumerEntrypointsForSettings,
|
|
8
|
+
enabledGeneratedArtifacts,
|
|
9
|
+
normalizeHarnessSettings,
|
|
10
|
+
workspaceEntrypointsForSettings,
|
|
11
|
+
} from "./generated-harness-contract.mjs";
|
|
12
|
+
import {
|
|
13
|
+
collectFiles,
|
|
14
|
+
failIfErrors,
|
|
15
|
+
readJson,
|
|
16
|
+
repoRoot,
|
|
17
|
+
} from "./lib.mjs";
|
|
18
|
+
|
|
19
|
+
const artifactPath = "examples/generated-harness-tree.md";
|
|
20
|
+
const writeMode = process.argv.includes("--write");
|
|
21
|
+
const exampleConfigs = await collectFiles("examples", (relativePath) => relativePath.endsWith("harness.config.json"));
|
|
22
|
+
const expectedVariants = new Map([
|
|
23
|
+
["openai-only", (config) => config.models.openai && !config.models.anthropic],
|
|
24
|
+
["anthropic-only", (config) => !config.models.openai && config.models.anthropic],
|
|
25
|
+
["openai-and-anthropic", (config) => config.models.openai && config.models.anthropic],
|
|
26
|
+
]);
|
|
27
|
+
const genericNamePattern = /^example-(frontend|api|worker|platform)$/;
|
|
28
|
+
const errors = [];
|
|
29
|
+
const configs = [];
|
|
30
|
+
|
|
31
|
+
for (const configPath of exampleConfigs) {
|
|
32
|
+
const config = await readJson(configPath);
|
|
33
|
+
configs.push({ configPath, config });
|
|
34
|
+
assertGenericConfig(configPath, config);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
for (const [variant, predicate] of expectedVariants) {
|
|
38
|
+
if (!configs.some(({ config }) => predicate(config))) {
|
|
39
|
+
errors.push(`examples must include an ${variant} harness.config.json variant.`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const artifact = renderArtifact(configs);
|
|
44
|
+
const artifactAbsolutePath = path.join(repoRoot, artifactPath);
|
|
45
|
+
|
|
46
|
+
if (writeMode) {
|
|
47
|
+
await writeFile(artifactAbsolutePath, artifact);
|
|
48
|
+
} else {
|
|
49
|
+
const current = await readFile(artifactAbsolutePath, "utf8").catch(() => "");
|
|
50
|
+
if (current !== artifact) {
|
|
51
|
+
errors.push(`${artifactPath} is stale. Run node scripts/check-examples.mjs --write.`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
failIfErrors("Examples check", errors);
|
|
56
|
+
|
|
57
|
+
function assertGenericConfig(configPath, config) {
|
|
58
|
+
const names = [
|
|
59
|
+
config.project?.slug,
|
|
60
|
+
config.project?.harnessRepoName?.replace(/-structor$/, ""),
|
|
61
|
+
...(config.consumers ?? []).flatMap((consumer) => [consumer.name, consumer.path?.replace(/^\.\//, "")]),
|
|
62
|
+
].filter(Boolean);
|
|
63
|
+
|
|
64
|
+
for (const name of names) {
|
|
65
|
+
if (!genericNamePattern.test(name)) {
|
|
66
|
+
errors.push(`${configPath}: ${name} must use a generic example-* name.`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function renderArtifact(items) {
|
|
72
|
+
const sections = items
|
|
73
|
+
.slice()
|
|
74
|
+
.sort(compareConfigs)
|
|
75
|
+
.map(({ configPath, config }) => renderSection(configPath, config));
|
|
76
|
+
|
|
77
|
+
return [
|
|
78
|
+
"# Generated Harness Tree Artifact",
|
|
79
|
+
"",
|
|
80
|
+
"This checked-in file is a text artifact for public inspection. It is not a generated harness directory, and no generated harness output is committed here.",
|
|
81
|
+
"",
|
|
82
|
+
"The tree below is derived from the checked-in example configs and `scripts/generated-harness-contract.mjs`. `npm run check:ci` verifies that this artifact stays synchronized with the expected generated file, workspace pointer, and consumer pointer surfaces.",
|
|
83
|
+
"",
|
|
84
|
+
...sections,
|
|
85
|
+
"",
|
|
86
|
+
].join("\n");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function compareConfigs(left, right) {
|
|
90
|
+
return variantRank(left.config) - variantRank(right.config)
|
|
91
|
+
|| left.configPath.localeCompare(right.configPath);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function variantRank(config) {
|
|
95
|
+
if (config.models.openai && !config.models.anthropic) return 0;
|
|
96
|
+
if (!config.models.openai && config.models.anthropic) return 1;
|
|
97
|
+
return 2;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function renderSection(configPath, config) {
|
|
101
|
+
const settings = normalizeHarnessSettings(config);
|
|
102
|
+
const harnessRepoName = config.project.harnessRepoName;
|
|
103
|
+
const generatedFiles = [
|
|
104
|
+
".structor/manifest.json",
|
|
105
|
+
...enabledGeneratedArtifacts(settings).map(artifactTargetPath),
|
|
106
|
+
].sort();
|
|
107
|
+
const workspaceEntrypoints = workspaceEntrypointsForSettings(settings).sort(compareEntrypoints);
|
|
108
|
+
const consumerEntrypoints = consumerEntrypointsForSettings(settings).sort(compareEntrypoints);
|
|
109
|
+
const lines = [
|
|
110
|
+
`## ${variantLabel(config)}`,
|
|
111
|
+
"",
|
|
112
|
+
`Source config: \`${configPath}\``,
|
|
113
|
+
"",
|
|
114
|
+
"```text",
|
|
115
|
+
"workspace/",
|
|
116
|
+
` ${harnessRepoName}/`,
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
for (const file of generatedFiles) {
|
|
120
|
+
lines.push(` ${file}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for (const entrypoint of workspaceEntrypoints) {
|
|
124
|
+
lines.push(` ${entrypoint.path} # workspace pointer to ${harnessRepoName}/${entrypoint.source}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
for (const consumer of config.consumers) {
|
|
128
|
+
lines.push(` ${consumer.name}/`);
|
|
129
|
+
for (const entrypoint of consumerEntrypoints) {
|
|
130
|
+
lines.push(` ${entrypoint.path} # consumer pointer to ${harnessRepoName}/${entrypoint.source}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
lines.push("```", "");
|
|
135
|
+
return lines.join("\n");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function compareEntrypoints(left, right) {
|
|
139
|
+
return left.path.localeCompare(right.path);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function variantLabel(config) {
|
|
143
|
+
if (config.models.openai && !config.models.anthropic) return "OpenAI-only example";
|
|
144
|
+
if (!config.models.openai && config.models.anthropic) return "Anthropic-only example";
|
|
145
|
+
return "OpenAI and Anthropic example";
|
|
146
|
+
}
|
|
@@ -7,6 +7,8 @@ import { collectFiles, failIfErrors, repoRoot } from "./lib.mjs";
|
|
|
7
7
|
const errors = [];
|
|
8
8
|
const activeFiles = await collectFiles(".", (file) => {
|
|
9
9
|
if (file.startsWith(".git/")) return false;
|
|
10
|
+
if (file.startsWith(".claude/worktrees/")) return false;
|
|
11
|
+
if (file.startsWith(".codex/worktrees/")) return false;
|
|
10
12
|
if (file.startsWith("template/")) return false;
|
|
11
13
|
return [".md", ".json", ".mjs"].some((suffix) => file.endsWith(suffix));
|
|
12
14
|
});
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { exists, failIfErrors, repoRoot } from "./lib.mjs";
|
|
6
|
+
|
|
7
|
+
const errors = [];
|
|
8
|
+
|
|
9
|
+
const skippedDirectories = new Set([
|
|
10
|
+
".git",
|
|
11
|
+
".cache",
|
|
12
|
+
".next",
|
|
13
|
+
".turbo",
|
|
14
|
+
"coverage",
|
|
15
|
+
"dist",
|
|
16
|
+
"node_modules",
|
|
17
|
+
"tmp",
|
|
18
|
+
"temp",
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
// Hygiene scanning is deny-list based: every publishable file is scanned for
|
|
22
|
+
// secrets unless its extension is known-binary. An allow-list of extensions
|
|
23
|
+
// silently skipped extensionless files (e.g. `prod-private-key`) that npm pack
|
|
24
|
+
// would still publish, so we invert the check.
|
|
25
|
+
const binaryExtensions = new Set([
|
|
26
|
+
".png",
|
|
27
|
+
".jpg",
|
|
28
|
+
".jpeg",
|
|
29
|
+
".gif",
|
|
30
|
+
".webp",
|
|
31
|
+
".ico",
|
|
32
|
+
".bmp",
|
|
33
|
+
".tiff",
|
|
34
|
+
".pdf",
|
|
35
|
+
".woff",
|
|
36
|
+
".woff2",
|
|
37
|
+
".ttf",
|
|
38
|
+
".otf",
|
|
39
|
+
".eot",
|
|
40
|
+
".zip",
|
|
41
|
+
".gz",
|
|
42
|
+
".tgz",
|
|
43
|
+
".tar",
|
|
44
|
+
".bz2",
|
|
45
|
+
".xz",
|
|
46
|
+
".7z",
|
|
47
|
+
".rar",
|
|
48
|
+
".jar",
|
|
49
|
+
".mp3",
|
|
50
|
+
".mp4",
|
|
51
|
+
".mov",
|
|
52
|
+
".avi",
|
|
53
|
+
".wav",
|
|
54
|
+
".webm",
|
|
55
|
+
".wasm",
|
|
56
|
+
".node",
|
|
57
|
+
".bin",
|
|
58
|
+
".exe",
|
|
59
|
+
".dll",
|
|
60
|
+
".so",
|
|
61
|
+
".dylib",
|
|
62
|
+
".class",
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
// Patterns for high-confidence secret material that must never be published.
|
|
66
|
+
const secretPatterns = [
|
|
67
|
+
{
|
|
68
|
+
pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY-----/,
|
|
69
|
+
description: "a private key block",
|
|
70
|
+
},
|
|
71
|
+
{ pattern: /\bsk_live_[A-Za-z0-9]{16,}/, description: "a Stripe live secret key" },
|
|
72
|
+
{ pattern: /\brk_live_[A-Za-z0-9]{16,}/, description: "a Stripe live restricted key" },
|
|
73
|
+
{ pattern: /\bAKIA[0-9A-Z]{16}\b/, description: "an AWS access key id" },
|
|
74
|
+
{ pattern: /\bghp_[A-Za-z0-9]{36}\b/, description: "a GitHub personal access token" },
|
|
75
|
+
{ pattern: /\bgithub_pat_[A-Za-z0-9_]{40,}/, description: "a GitHub fine-grained token" },
|
|
76
|
+
{ pattern: /\bxox[baprs]-[A-Za-z0-9-]{10,}/, description: "a Slack token" },
|
|
77
|
+
{ pattern: /\bAIza[0-9A-Za-z_-]{35}\b/, description: "a Google API key" },
|
|
78
|
+
{ pattern: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/, description: "a JWT" },
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
const allowedRepositoryUrls = new Set([
|
|
82
|
+
"https://github.com/nicolaycamacho/structor",
|
|
83
|
+
"https://github.com/nicolaycamacho/structor.git",
|
|
84
|
+
"git+https://github.com/nicolaycamacho/structor.git",
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
const forbiddenProjectTermsEnvVar = "HARNESS_FORBIDDEN_PROJECT_TERMS";
|
|
88
|
+
const configuredForbiddenProjectTerms = (process.env[forbiddenProjectTermsEnvVar] ?? "")
|
|
89
|
+
.split(",")
|
|
90
|
+
.map((term) => term.trim())
|
|
91
|
+
.filter(Boolean);
|
|
92
|
+
|
|
93
|
+
const forbiddenProjectTermPatterns = [...new Set(configuredForbiddenProjectTerms)]
|
|
94
|
+
.map((term) => new RegExp(`\\b${escapeRegExp(term)}\\b`, "i"));
|
|
95
|
+
|
|
96
|
+
await checkCommittedGeneratedOutput();
|
|
97
|
+
|
|
98
|
+
const activeFiles = await collectPublishableFiles();
|
|
99
|
+
for (const relativePath of activeFiles) {
|
|
100
|
+
const content = await readFile(path.join(repoRoot, relativePath), "utf8");
|
|
101
|
+
checkContent(relativePath, content);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
failIfErrors("Public hygiene check", errors);
|
|
105
|
+
|
|
106
|
+
async function checkCommittedGeneratedOutput() {
|
|
107
|
+
const generatedRoot = path.join(repoRoot, "generated");
|
|
108
|
+
if (await exists(generatedRoot)) {
|
|
109
|
+
errors.push("generated/ exists; generated harness output must not be committed.");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const entries = await readdir(repoRoot, { withFileTypes: true });
|
|
113
|
+
for (const entry of entries) {
|
|
114
|
+
if (!entry.isDirectory()) continue;
|
|
115
|
+
if (skippedDirectories.has(entry.name)) continue;
|
|
116
|
+
|
|
117
|
+
const manifestPath = path.join(repoRoot, entry.name, ".structor", "manifest.json");
|
|
118
|
+
if (await exists(manifestPath)) {
|
|
119
|
+
errors.push(`${entry.name}/ looks like generated harness output and must not be committed.`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function shouldScanFile(relativePath) {
|
|
125
|
+
if (relativePath === "scripts/check-public-hygiene.mjs") {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
if (relativePath === "package-lock.json") {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
return !binaryExtensions.has(path.extname(relativePath).toLowerCase());
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Scan exactly the set of files npm would publish (the package.json `files`
|
|
135
|
+
// allow-list plus the always-included package.json), so extensionless secret
|
|
136
|
+
// files inside published directories are caught while local-only files such as
|
|
137
|
+
// `.git` or `*.local.json` are not falsely flagged.
|
|
138
|
+
async function collectPublishableFiles() {
|
|
139
|
+
const pkg = JSON.parse(await readFile(path.join(repoRoot, "package.json"), "utf8"));
|
|
140
|
+
const entries = new Set(["package.json", ...(Array.isArray(pkg.files) ? pkg.files : [])]);
|
|
141
|
+
const files = new Set();
|
|
142
|
+
|
|
143
|
+
async function walk(currentRelativePath) {
|
|
144
|
+
const dirEntries = await readdir(path.join(repoRoot, currentRelativePath), { withFileTypes: true });
|
|
145
|
+
for (const entry of dirEntries) {
|
|
146
|
+
const relativePath = path.posix.join(currentRelativePath, entry.name);
|
|
147
|
+
if (entry.isDirectory()) {
|
|
148
|
+
if (!skippedDirectories.has(entry.name)) await walk(relativePath);
|
|
149
|
+
} else if (entry.isFile() && shouldScanFile(relativePath)) {
|
|
150
|
+
files.add(relativePath);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
for (const entry of entries) {
|
|
156
|
+
const normalized = entry.replace(/\/+$/, "");
|
|
157
|
+
const absolute = path.join(repoRoot, normalized);
|
|
158
|
+
if (!(await exists(absolute))) continue;
|
|
159
|
+
const stats = await stat(absolute);
|
|
160
|
+
if (stats.isDirectory()) {
|
|
161
|
+
await walk(normalized);
|
|
162
|
+
} else if (shouldScanFile(normalized)) {
|
|
163
|
+
files.add(normalized);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return [...files].sort();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function checkContent(relativePath, content) {
|
|
171
|
+
const hasPersonalPath =
|
|
172
|
+
/\/Users\/[^/\s]+/.test(content) ||
|
|
173
|
+
/\/home\/[^/\s]+/.test(content) ||
|
|
174
|
+
/[A-Za-z]:\\Users\\[^\\\s]+/.test(content);
|
|
175
|
+
if (hasPersonalPath) {
|
|
176
|
+
errors.push(`${relativePath} contains an obvious local personal path.`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i.test(content)) {
|
|
180
|
+
errors.push(`${relativePath} contains an email address.`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
for (const { pattern, description } of secretPatterns) {
|
|
184
|
+
if (pattern.test(content)) {
|
|
185
|
+
errors.push(`${relativePath} contains ${description}.`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
for (const repoUrl of findRepositoryUrls(content)) {
|
|
190
|
+
if (allowedRepositoryUrls.has(repoUrl)) continue;
|
|
191
|
+
if (isPrivateLookingRepositoryUrl(repoUrl)) {
|
|
192
|
+
errors.push(`${relativePath} contains a private-looking repository URL: ${repoUrl}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
for (const pattern of forbiddenProjectTermPatterns) {
|
|
197
|
+
if (pattern.test(content)) {
|
|
198
|
+
errors.push(`${relativePath} contains a configured forbidden project term.`);
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function findRepositoryUrls(content) {
|
|
205
|
+
const urls = new Set();
|
|
206
|
+
const patterns = [
|
|
207
|
+
/\b(?:git\+)?https:\/\/(?:github|gitlab|bitbucket)\.[^\s`'")<>]+\/[^\s`'")<>]+\/[^\s`'")<>]+/gi,
|
|
208
|
+
/\bgit@(?:github|gitlab|bitbucket)\.[^:\s]+:[^\s`'")<>]+\/[^\s`'")<>]+/gi,
|
|
209
|
+
/\bssh:\/\/git@(?:github|gitlab|bitbucket)\.[^\s`'")<>]+\/[^\s`'")<>]+\/[^\s`'")<>]+/gi,
|
|
210
|
+
];
|
|
211
|
+
|
|
212
|
+
for (const pattern of patterns) {
|
|
213
|
+
for (const match of content.matchAll(pattern)) {
|
|
214
|
+
urls.add(match[0].replace(/[.,;:]+$/, ""));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return urls;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function isPrivateLookingRepositoryUrl(repoUrl) {
|
|
222
|
+
if (/^git@/i.test(repoUrl) || /^ssh:\/\//i.test(repoUrl)) return true;
|
|
223
|
+
if (/\b(internal|private|proprietary|confidential)\b/i.test(repoUrl)) return true;
|
|
224
|
+
if (!isAllowedPublicRepositoryUrl(repoUrl)) {
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function isAllowedPublicRepositoryUrl(repoUrl) {
|
|
231
|
+
const normalizedRepoUrl = repoUrl.replace(/^git\+/i, "");
|
|
232
|
+
let url;
|
|
233
|
+
try {
|
|
234
|
+
url = new URL(normalizedRepoUrl);
|
|
235
|
+
} catch {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (url.protocol !== "https:" || url.hostname !== "github.com") return false;
|
|
240
|
+
|
|
241
|
+
const segments = url.pathname.split("/").filter(Boolean);
|
|
242
|
+
const [owner, repo] = segments;
|
|
243
|
+
const normalizedRepo = repo?.replace(/\.git$/i, "");
|
|
244
|
+
return owner === "nicolaycamacho" && normalizedRepo === "structor";
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function escapeRegExp(value) {
|
|
248
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
249
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { failIfErrors, repoRoot } from "./lib.mjs";
|
|
6
|
+
|
|
7
|
+
const schemasDirectory = "schemas";
|
|
8
|
+
const activeSchemas = new Set([
|
|
9
|
+
"contract-manifest.schema.json",
|
|
10
|
+
"harness-config.schema.json",
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
const entries = await readdir(path.join(repoRoot, schemasDirectory), { withFileTypes: true });
|
|
14
|
+
const errors = [];
|
|
15
|
+
|
|
16
|
+
for (const entry of entries) {
|
|
17
|
+
if (!entry.isFile() || !entry.name.endsWith(".schema.json")) continue;
|
|
18
|
+
|
|
19
|
+
const relativePath = `${schemasDirectory}/${entry.name}`;
|
|
20
|
+
if (!activeSchemas.has(entry.name)) {
|
|
21
|
+
errors.push(`${relativePath} is not an active Structor schema contract.`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const content = await readFile(path.join(repoRoot, relativePath), "utf8");
|
|
25
|
+
if (content.includes("example.com")) {
|
|
26
|
+
errors.push(`${relativePath} must not use placeholder example.com identifiers.`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
JSON.parse(content);
|
|
31
|
+
} catch {
|
|
32
|
+
errors.push(`${relativePath} must be valid JSON.`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
for (const schemaName of activeSchemas) {
|
|
37
|
+
if (!entries.some((entry) => entry.isFile() && entry.name === schemaName)) {
|
|
38
|
+
errors.push(`${schemasDirectory}/${schemaName} is missing.`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
failIfErrors("Schema check", errors);
|
|
@@ -1,110 +1,27 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { exists, failIfErrors, repoRoot } from "./lib.mjs";
|
|
4
|
+
import { collectFiles, exists, failIfErrors, repoRoot } from "./lib.mjs";
|
|
5
|
+
import {
|
|
6
|
+
generatedHarnessContractErrors,
|
|
7
|
+
generatedHarnessTemplatePaths,
|
|
8
|
+
} from "./generated-harness-contract.mjs";
|
|
5
9
|
|
|
6
|
-
const requiredFiles =
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
"template/.claude/rules/harness-client-surfaces.md.tpl",
|
|
11
|
-
"template/.claude/settings.json.tpl",
|
|
12
|
-
"template/.codex/hooks.json.tpl",
|
|
13
|
-
"template/README.md.tpl",
|
|
14
|
-
"template/ai/AGENTS.md.tpl",
|
|
15
|
-
"template/ai/HUB.md.tpl",
|
|
16
|
-
"template/ai/context.md.tpl",
|
|
17
|
-
"template/ai/HARNESS.md.tpl",
|
|
18
|
-
"template/ai/HARNESS-ENGINEERING.md.tpl",
|
|
19
|
-
"template/ai/READINESS.md.tpl",
|
|
20
|
-
"template/ai/QUALITY.md.tpl",
|
|
21
|
-
"template/ai/DECISIONS.md.tpl",
|
|
22
|
-
"template/ai/PRODUCT-SUMMARY.md.tpl",
|
|
23
|
-
"template/ai/PRODUCT.md.tpl",
|
|
24
|
-
"template/ai/ARCHITECTURE.md.tpl",
|
|
25
|
-
"template/ai/DESIGN.md.tpl",
|
|
26
|
-
"template/ai/WORKFLOW.md.tpl",
|
|
27
|
-
"template/ai/VERSIONING.md.tpl",
|
|
28
|
-
"template/ai/CODEX-HOOKS.md.tpl",
|
|
29
|
-
"template/ai/RUNNER-SAFETY.md.tpl",
|
|
30
|
-
"template/ai/RUNNER-READINESS.md.tpl",
|
|
31
|
-
"template/ai/AGENT-GARBAGE-COLLECTION.md.tpl",
|
|
32
|
-
"template/ai/knowledge-manifest.json.tpl",
|
|
33
|
-
"template/ai/workspace/REPOS.md.tpl",
|
|
34
|
-
"template/ai/workspace/SYSTEM-MAP.md.tpl",
|
|
35
|
-
"template/ai/workspace/SESSION-BOOTSTRAP.md.tpl",
|
|
36
|
-
"template/ai/workspace/LOCAL-STACK.md.tpl",
|
|
37
|
-
"template/ai/workspace/TEST-STRATEGY.md.tpl",
|
|
38
|
-
"template/ai/model-overlays/openai/AGENTS.md.tpl",
|
|
39
|
-
"template/ai/model-overlays/anthropic/CLAUDE.md.tpl",
|
|
40
|
-
"template/consumer/AGENTS.md.tpl",
|
|
41
|
-
"template/consumer/CLAUDE.md.tpl",
|
|
42
|
-
"template/consumer/.claude/CLAUDE.md.tpl",
|
|
43
|
-
"template/workspace/AGENTS.md.tpl",
|
|
44
|
-
"template/workspace/CLAUDE.md.tpl",
|
|
45
|
-
"template/workspace/.claude/CLAUDE.md.tpl",
|
|
46
|
-
"template/workspace/.claude/rules/harness-client-surfaces.md.tpl",
|
|
47
|
-
"template/workspace/.claude/settings.json.tpl",
|
|
48
|
-
"template/ai/contracts/README.md.tpl",
|
|
49
|
-
"template/ai/contracts/repo-boundaries.md.tpl",
|
|
50
|
-
"template/ai/contracts/app-legibility.md.tpl",
|
|
51
|
-
"template/ai/contracts/api-boundary.md.tpl",
|
|
52
|
-
"template/ai/contracts/security-boundary.md.tpl",
|
|
53
|
-
"template/ai/contracts/repo-boundaries.contract.json.tpl",
|
|
54
|
-
"template/ai/contracts/app-legibility.contract.json.tpl",
|
|
55
|
-
"template/ai/contracts/api-boundary.contract.json.tpl",
|
|
56
|
-
"template/ai/contracts/security-boundary.contract.json.tpl",
|
|
57
|
-
"template/ai/contracts/codex-hooks.md.tpl",
|
|
58
|
-
"template/ai/contracts/codex-hooks.contract.json.tpl",
|
|
59
|
-
"template/ai/contracts/release-flow.md.tpl",
|
|
60
|
-
"template/ai/contracts/release-flow.contract.json.tpl",
|
|
61
|
-
"template/ai/contracts/github-safety.md.tpl",
|
|
62
|
-
"template/ai/contracts/github-safety.contract.json.tpl",
|
|
63
|
-
"template/ai/templates/README.md.tpl",
|
|
64
|
-
"template/ai/templates/task-brief-template.md.tpl",
|
|
65
|
-
"template/ai/templates/issue-template.md.tpl",
|
|
66
|
-
"template/ai/templates/fixtures/issues/valid-ready.md.tpl",
|
|
67
|
-
"template/ai/templates/fixtures/issues/invalid-placeholder.md.tpl",
|
|
68
|
-
"template/ai/templates/fixtures/issues/invalid-protected-surface.md.tpl",
|
|
69
|
-
"template/ai/specs/README.md.tpl",
|
|
70
|
-
"template/ai/skills/README.md.tpl",
|
|
71
|
-
"template/ai/skills/review-architecture.md.tpl",
|
|
72
|
-
"template/ai/skills/review-security.md.tpl",
|
|
73
|
-
"template/ai/skills/review-contract-drift.md.tpl",
|
|
74
|
-
"template/ai/skills/review-governance-drift.md.tpl",
|
|
75
|
-
"template/ai/plans/README.md.tpl",
|
|
76
|
-
"template/ai/plans/tech-debt.md.tpl",
|
|
77
|
-
"template/scripts/validate-governance.mjs.tpl",
|
|
78
|
-
"template/scripts/check-template-governance.mjs.tpl",
|
|
79
|
-
"template/scripts/check-readiness.mjs.tpl",
|
|
80
|
-
"template/scripts/check-task-template.mjs.tpl",
|
|
81
|
-
"template/scripts/check-issue-template.mjs.tpl",
|
|
82
|
-
"template/scripts/check-knowledge-manifest.mjs.tpl",
|
|
83
|
-
"template/scripts/check-plans.mjs.tpl",
|
|
84
|
-
"template/scripts/check-review-skills.mjs.tpl",
|
|
85
|
-
"template/scripts/check-garbage-collection.mjs.tpl",
|
|
86
|
-
"template/scripts/check-contract-manifests.mjs.tpl",
|
|
87
|
-
"template/scripts/generate-html-views.mjs.tpl",
|
|
88
|
-
"template/scripts/check-html-views.mjs.tpl",
|
|
89
|
-
"template/scripts/check-codex-hooks.mjs.tpl",
|
|
90
|
-
"template/scripts/check-claude-compatibility.mjs.tpl",
|
|
91
|
-
"template/scripts/check-overlay-drift.mjs.tpl",
|
|
92
|
-
"template/scripts/bootstrap-codex-worktree.mjs.tpl",
|
|
93
|
-
"template/scripts/check-worktrees.mjs.tpl",
|
|
94
|
-
"template/scripts/check-worktree-bootstrap-fixtures.mjs.tpl",
|
|
95
|
-
"template/scripts/lib/worktree-bootstrap.mjs.tpl",
|
|
96
|
-
"template/scripts/fixtures/worktrees/README.md.tpl",
|
|
97
|
-
"template/scripts/bootstrap-workspace.mjs.tpl",
|
|
98
|
-
"template/scripts/check-workspace.mjs.tpl",
|
|
99
|
-
"template/scripts/hooks/codex-hook.mjs.tpl",
|
|
100
|
-
"template/scripts/hooks/lib/codex-hooks-core.mjs.tpl"
|
|
101
|
-
];
|
|
10
|
+
const requiredFiles = generatedHarnessTemplatePaths();
|
|
11
|
+
const declared = new Set(requiredFiles);
|
|
12
|
+
const actualTemplateFiles = await collectFiles("template", (relativePath) => relativePath.endsWith(".tpl"));
|
|
13
|
+
const errors = generatedHarnessContractErrors();
|
|
102
14
|
|
|
103
|
-
const errors = [];
|
|
104
15
|
for (const relativePath of requiredFiles) {
|
|
105
16
|
if (!(await exists(path.join(repoRoot, relativePath)))) {
|
|
106
17
|
errors.push(`missing ${relativePath}`);
|
|
107
18
|
}
|
|
108
19
|
}
|
|
109
20
|
|
|
21
|
+
for (const relativePath of actualTemplateFiles) {
|
|
22
|
+
if (!declared.has(relativePath)) {
|
|
23
|
+
errors.push(`${relativePath} is not declared in scripts/generated-harness-contract.mjs`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
110
27
|
failIfErrors("Template file check", errors);
|