human-to-code 0.0.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.
@@ -0,0 +1,12 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(npm --version)",
5
+ "Bash(git --version)",
6
+ "Bash(node --help)",
7
+ "Bash(node src/cli.ts --help)",
8
+ "Bash(node --test)",
9
+ "Bash(node src/cli.ts /private/tmp/claude-501/-Users-shazi-Projects-Human-To-Code/3580a972-a849-48c3-a57a-17d60a5c7ac2/scratchpad/fixture --provider openai)"
10
+ ]
11
+ }
12
+ }
@@ -0,0 +1,18 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: actions/setup-node@v4
14
+ with:
15
+ node-version: "24"
16
+ # Zero runtime dependencies — nothing to install.
17
+ - name: Run tests
18
+ run: node --test
@@ -0,0 +1,40 @@
1
+ # Contributing to human-to-code
2
+
3
+ Thanks for helping out! This project is in early development, so issues, ideas, and PRs are all welcome.
4
+
5
+ ## Development setup
6
+
7
+ You only need **Node ≥ 23.6** — it runs the TypeScript source directly, so there's no build step and there are **no dependencies to install**.
8
+
9
+ ```bash
10
+ git clone https://github.com/sharjeelbaig/human-to-code
11
+ cd human-to-code
12
+ node src/cli.ts --help # run the CLI
13
+ node --test # run the test suite
14
+ ```
15
+
16
+ ## Project layout
17
+
18
+ | Path | What it is |
19
+ | ------------------ | --------------------------------------------------------------------- |
20
+ | `src/config.ts` | Structured JSON config loader + validator |
21
+ | `src/discovery.ts` | Finds and classifies `.human` / `.strict.human` files; security gates |
22
+ | `src/cli.ts` | Command-line entry point |
23
+ | `test/` | `node:test` suites |
24
+
25
+ ## The one rule that keeps the project safe
26
+
27
+ The pipeline is `human → strict → code`. The LLM is confined to `human → strict`; the `strict → code` step is a **deterministic generator with no LLM and no network**.
28
+
29
+ - Keep `strict → code` deterministic — same IR in, identical code out.
30
+ - **Never add a raw-code passthrough** to the strict grammar. It's the one change that would let a malicious `.human` file smuggle arbitrary code past review. If you ever truly need one, it must be explicitly flagged and gated.
31
+
32
+ ## Submitting changes
33
+
34
+ 1. Open an issue first for anything non-trivial so we can agree on direction.
35
+ 2. Keep PRs focused; add or update `node:test` cases for behavior changes.
36
+ 3. Make sure `node --test` passes before pushing.
37
+
38
+ ## Reporting security issues
39
+
40
+ Please don't open a public issue for vulnerabilities — see [SECURITY.md](SECURITY.md).
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 the human-to-code authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/Readme.md ADDED
@@ -0,0 +1,84 @@
1
+ <p align="center">
2
+ <img src="assets/banner.svg" alt="human-to-code — write intent in plain language, compile it to real code" width="100%">
3
+ </p>
4
+
5
+ <p align="center">
6
+ <a href="#status--roadmap"><img alt="status: alpha" src="https://img.shields.io/badge/status-alpha-orange"></a>
7
+ <img alt="node >= 23.6" src="https://img.shields.io/badge/node-%E2%89%A5%2023.6-brightgreen">
8
+ <a href="LICENSE"><img alt="license: MIT" src="https://img.shields.io/badge/license-MIT-blue"></a>
9
+ <a href="CONTRIBUTING.md"><img alt="PRs welcome" src="https://img.shields.io/badge/PRs-welcome-brightgreen"></a>
10
+ <img alt="zero dependencies" src="https://img.shields.io/badge/deps-0-informational">
11
+ </p>
12
+
13
+ `human-to-code` turns natural-language **`.human`** files into TypeScript, JavaScript, or Python — not by piping your whole codebase through an LLM and hoping, but through a small, reviewable intermediate representation that keeps the output reproducible.
14
+
15
+ ```text
16
+ foo.human → foo.strict.human → foo.ts
17
+ plain English strict IR (reviewed) real code
18
+ └── LLM ──┘ └── deterministic compiler ──┘
19
+ ```
20
+
21
+ The LLM only writes the **middle** layer. Turning that layer into code is a plain compiler: **same IR in, identical code out — every run.**
22
+
23
+ ## Why the middle layer?
24
+
25
+ - 🔁 **Reproducible** — `strict → code` uses no LLM, so builds are bit-for-bit stable.
26
+ - 🔗 **Coherent across files** — a shared symbol table keeps names and signatures aligned, so a function defined in one file is called by the same name in another.
27
+ - 🛡️ **Safe to review** — you diff a small, constrained IR instead of trusting freeform generated code. See [SECURITY.md](SECURITY.md).
28
+
29
+ ## Quick start
30
+
31
+ > [!WARNING]
32
+ > **Alpha.** The deterministic core (config, discovery, CLI planning) works today. The `.human → strict → code` generators are in progress, and it isn't on npm yet.
33
+
34
+ ```bash
35
+ git clone https://github.com/sharjeelbaig/human-to-code
36
+ cd human-to-code
37
+ node src/cli.ts --help # Node >= 23.6 runs the TypeScript directly — no build step
38
+ ```
39
+
40
+ Planned published usage:
41
+
42
+ ```bash
43
+ npx human-to-code . # compile every .human file in the project
44
+ npx human-to-code --init # write a default config
45
+ npx human-to-code --check # CI gate: fail if a .human has no strict IR
46
+ ```
47
+
48
+ ## Configuration
49
+
50
+ `human-to-code.config.json` (plain JSON — never parsed by an LLM):
51
+
52
+ ```json
53
+ {
54
+ "language": "typescript",
55
+ "filesToIgnore": ["node_modules", ".git", "dist"],
56
+ "allowNonHumanFiles": false,
57
+ "provider": { "name": "anthropic", "model": "claude-opus-4-8" }
58
+ }
59
+ ```
60
+
61
+ Provider credentials come from **environment variables**, never a committed file. `secrets.human` is git-ignored, and the tool refuses to run if it's git-tracked.
62
+
63
+ ## Status & roadmap
64
+
65
+ - [x] Deterministic core — config, discovery, CLI, security gates
66
+ - [ ] `.strict.human` grammar + parser
67
+ - [ ] Deterministic `strict → code` backend (TypeScript first)
68
+ - [ ] `human → strict` LLM front-end (constrained output, hash-locked)
69
+ - [ ] `--watch`, cost reporting, more target languages
70
+
71
+ <details>
72
+ <summary>How it fits together (architecture)</summary>
73
+
74
+ The one non-deterministic step (`human → strict`) is confined to a single LLM call whose output is a committed, diffable IR. Everything downstream is a deterministic compiler with snapshot tests. A hand-written `.strict.human` always wins over a generated one, so you can drop to precise control whenever you want. Full design notes live in the project plan.
75
+
76
+ </details>
77
+
78
+ ## Contributing
79
+
80
+ Issues and PRs welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). Run the tests with `node --test`.
81
+
82
+ ## License
83
+
84
+ [MIT](LICENSE) © the human-to-code authors
package/SECURITY.md ADDED
@@ -0,0 +1,20 @@
1
+ # Security Policy
2
+
3
+ `human-to-code` reads natural-language files and produces source code that people run, so it treats security as a first-class concern.
4
+
5
+ ## The security model
6
+
7
+ - **The LLM can only produce the strict IR, never code directly.** Turning the IR into code is deterministic. A malicious `.human` file can, at worst, produce malicious *strict*, which is a small, constrained, reviewable artifact — not freeform code.
8
+ - **The strict grammar has no raw-code escape hatch.** This is what keeps the review step meaningful; see [CONTRIBUTING.md](CONTRIBUTING.md).
9
+ - **Secrets never reach a provider.** `secrets.human` and git-ignored files are excluded from any prompt context, and the tool refuses to run if `secrets.human` is git-tracked. Prefer environment variables or your OS keychain for credentials.
10
+ - **Filesystem safety.** Discovery never follows symlinks and stays within the project root.
11
+ - **Endpoints must use TLS.** A custom provider `baseUrl` must be `https://`.
12
+
13
+ ## Reporting a vulnerability
14
+
15
+ Please report suspected vulnerabilities **privately** — do not open a public issue.
16
+
17
+ - Use [GitHub private security advisories](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability) on this repository, **or**
18
+ - email the maintainers (add a contact address here before publishing).
19
+
20
+ We'll acknowledge your report, investigate, and coordinate a fix and disclosure timeline with you.
@@ -0,0 +1,38 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 360" width="1280" height="360" role="img" aria-label="human-to-code — write intent in plain language, compile it to real code">
2
+ <defs>
3
+ <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
4
+ <stop offset="0" stop-color="#0b1220"/>
5
+ <stop offset="1" stop-color="#111a2e"/>
6
+ </linearGradient>
7
+ <style>
8
+ .mono { font-family: 'SFMono-Regular','Menlo','Consolas',ui-monospace,monospace; }
9
+ .sans { font-family: 'Segoe UI',system-ui,-apple-system,'Helvetica Neue',sans-serif; }
10
+ </style>
11
+ </defs>
12
+
13
+ <rect x="0" y="0" width="1280" height="360" rx="24" fill="url(#bg)"/>
14
+ <rect x="0" y="0" width="1280" height="6" rx="3" fill="#38bdf8" opacity="0.9"/>
15
+
16
+ <text x="640" y="118" text-anchor="middle" class="mono" font-size="62" font-weight="700" fill="#f8fafc">human-to-code</text>
17
+ <text x="640" y="158" text-anchor="middle" class="sans" font-size="21" fill="#94a3b8">Write intent in plain language. Compile it to real code.</text>
18
+
19
+ <!-- pipeline: human -> strict -> code -->
20
+ <g>
21
+ <rect x="300" y="212" width="170" height="62" rx="14" fill="#f59e0b"/>
22
+ <text x="385" y="251" text-anchor="middle" class="mono" font-size="24" font-weight="700" fill="#1c1206">human</text>
23
+
24
+ <text x="495" y="252" text-anchor="middle" class="sans" font-size="30" fill="#64748b">&#8594;</text>
25
+
26
+ <rect x="520" y="212" width="240" height="62" rx="14" fill="#38bdf8"/>
27
+ <text x="640" y="251" text-anchor="middle" class="mono" font-size="24" font-weight="700" fill="#062033">strict</text>
28
+
29
+ <text x="785" y="252" text-anchor="middle" class="sans" font-size="30" fill="#64748b">&#8594;</text>
30
+
31
+ <rect x="810" y="212" width="170" height="62" rx="14" fill="#34d399"/>
32
+ <text x="895" y="251" text-anchor="middle" class="mono" font-size="24" font-weight="700" fill="#062018">code</text>
33
+ </g>
34
+
35
+ <text x="385" y="304" text-anchor="middle" class="sans" font-size="15" fill="#64748b">natural language</text>
36
+ <text x="640" y="304" text-anchor="middle" class="sans" font-size="15" fill="#64748b">reviewable IR &#183; deterministic</text>
37
+ <text x="895" y="304" text-anchor="middle" class="sans" font-size="15" fill="#64748b">TS &#183; JS &#183; Python</text>
38
+ </svg>
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "human-to-code",
3
+ "version": "0.0.1",
4
+ "description": "Compile natural-language .human files to code via a strict intermediate representation.",
5
+ "type": "module",
6
+ "bin": {
7
+ "human-to-code": "src/cli.ts"
8
+ },
9
+ "exports": {
10
+ ".": "./src/index.ts"
11
+ },
12
+ "engines": {
13
+ "node": ">=23.6.0"
14
+ },
15
+ "scripts": {
16
+ "start": "node src/cli.ts",
17
+ "test": "node --test",
18
+ "typecheck": "tsc --noEmit"
19
+ },
20
+ "keywords": [
21
+ "codegen",
22
+ "compiler",
23
+ "llm",
24
+ "natural-language",
25
+ "dsl"
26
+ ],
27
+ "license": "MIT"
28
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * human-to-code CLI (deterministic core).
4
+ *
5
+ * Implemented now: `--init`, discovery + planning report (`--dry-run` style),
6
+ * and `--check` (CI: are there human sources without an up-to-date strict IR?).
7
+ * The `human -> strict` and `strict -> code` steps are added in later build
8
+ * steps; until then the CLI reports the plan instead of generating code.
9
+ */
10
+
11
+ import { parseArgs } from "node:util";
12
+ import { writeFile } from "node:fs/promises";
13
+ import { existsSync } from "node:fs";
14
+ import { join, resolve } from "node:path";
15
+ import {
16
+ CONFIG_FILENAME,
17
+ ConfigError,
18
+ defaultConfigJson,
19
+ defaultModelFor,
20
+ loadConfig,
21
+ } from "./config.ts";
22
+ import { discover, secretsTrackedError } from "./discovery.ts";
23
+ import type { ProviderName } from "./types.ts";
24
+
25
+ const HELP = `human-to-code — compile .human files to code via a strict IR
26
+
27
+ Usage:
28
+ human-to-code [path] [options]
29
+
30
+ Arguments:
31
+ path Directory to scan (default: ".")
32
+
33
+ Options:
34
+ --init Write a default ${CONFIG_FILENAME} and exit
35
+ --dry-run Show the plan without generating anything (default today)
36
+ --check Exit non-zero if any .human lacks an up-to-date .strict.human
37
+ --file <path> Restrict to a single source file
38
+ --provider <name> Override provider (openai|anthropic|ollama|grok|gemini)
39
+ -h, --help Show this help
40
+
41
+ Exit codes: 0 ok · 1 error · 2 --check found stale/missing strict IR
42
+ `;
43
+
44
+ interface Cli {
45
+ path: string;
46
+ init: boolean;
47
+ dryRun: boolean;
48
+ check: boolean;
49
+ file?: string;
50
+ provider?: string;
51
+ help: boolean;
52
+ }
53
+
54
+ function parse(argv: string[]): Cli {
55
+ const { values, positionals } = parseArgs({
56
+ args: argv,
57
+ allowPositionals: true,
58
+ options: {
59
+ init: { type: "boolean", default: false },
60
+ "dry-run": { type: "boolean", default: false },
61
+ check: { type: "boolean", default: false },
62
+ file: { type: "string" },
63
+ provider: { type: "string" },
64
+ help: { type: "boolean", short: "h", default: false },
65
+ },
66
+ });
67
+ const cli: Cli = {
68
+ path: positionals[0] ?? ".",
69
+ init: values.init === true,
70
+ dryRun: values["dry-run"] === true,
71
+ check: values.check === true,
72
+ help: values.help === true,
73
+ };
74
+ if (typeof values.file === "string") cli.file = values.file;
75
+ if (typeof values.provider === "string") cli.provider = values.provider;
76
+ return cli;
77
+ }
78
+
79
+ const VALID_PROVIDERS: readonly ProviderName[] = [
80
+ "openai",
81
+ "anthropic",
82
+ "ollama",
83
+ "grok",
84
+ "gemini",
85
+ ];
86
+
87
+ async function runInit(root: string): Promise<number> {
88
+ const target = join(root, CONFIG_FILENAME);
89
+ if (existsSync(target)) {
90
+ console.error(`${CONFIG_FILENAME} already exists — not overwriting.`);
91
+ return 1;
92
+ }
93
+ await writeFile(target, defaultConfigJson(), "utf8");
94
+ console.log(`Wrote ${CONFIG_FILENAME}.`);
95
+ return 0;
96
+ }
97
+
98
+ export async function run(argv: string[]): Promise<number> {
99
+ let cli: Cli;
100
+ try {
101
+ cli = parse(argv);
102
+ } catch (err) {
103
+ console.error(err instanceof Error ? err.message : String(err));
104
+ console.error(HELP);
105
+ return 1;
106
+ }
107
+
108
+ if (cli.help) {
109
+ console.log(HELP);
110
+ return 0;
111
+ }
112
+
113
+ const root = resolve(cli.path);
114
+
115
+ if (cli.init) {
116
+ return runInit(root);
117
+ }
118
+
119
+ // Load + validate config (structured JSON, never LLM-parsed).
120
+ let config;
121
+ try {
122
+ ({ config } = await loadConfig(root));
123
+ } catch (err) {
124
+ if (err instanceof ConfigError) {
125
+ console.error(`Config error: ${err.message}`);
126
+ return 1;
127
+ }
128
+ throw err;
129
+ }
130
+
131
+ if (cli.provider) {
132
+ if (!VALID_PROVIDERS.includes(cli.provider as ProviderName)) {
133
+ console.error(
134
+ `Unknown --provider "${cli.provider}". Valid: ${VALID_PROVIDERS.join(", ")}.`,
135
+ );
136
+ return 1;
137
+ }
138
+ // A provider override carries that provider's current default model,
139
+ // rather than silently keeping the previous provider's model id.
140
+ const name = cli.provider as ProviderName;
141
+ config.provider.name = name;
142
+ config.provider.model = defaultModelFor(name);
143
+ delete config.provider.baseUrl;
144
+ }
145
+
146
+ // Discover sources.
147
+ const result = await discover(root, config.filesToIgnore);
148
+
149
+ // Security gate: refuse to run if secrets.human is git-tracked.
150
+ if (result.secrets) {
151
+ const msg = secretsTrackedError(result.secrets);
152
+ if (msg) {
153
+ console.error(msg);
154
+ return 1;
155
+ }
156
+ }
157
+
158
+ const strictPaths = new Set(result.strict.map((f) => f.relPath));
159
+
160
+ // Which .human files have no corresponding strict IR yet?
161
+ const humanFiltered = cli.file
162
+ ? result.human.filter((f) => f.relPath === toPosixRel(root, cli.file!))
163
+ : result.human;
164
+
165
+ const missingStrict = humanFiltered.filter(
166
+ (f) => f.strictSibling && !strictPaths.has(f.strictSibling),
167
+ );
168
+
169
+ // --check: CI gate.
170
+ if (cli.check) {
171
+ if (missingStrict.length > 0) {
172
+ console.error(
173
+ `--check failed: ${missingStrict.length} human file(s) without a strict IR:`,
174
+ );
175
+ for (const f of missingStrict) console.error(` ${f.relPath}`);
176
+ return 2;
177
+ }
178
+ console.log("--check passed: every .human has a .strict.human.");
179
+ return 0;
180
+ }
181
+
182
+ // Report the plan (generation is added in a later build step).
183
+ console.log(`Scanned ${result.root}`);
184
+ console.log(`Language: ${config.language}`);
185
+ console.log(
186
+ `Provider: ${config.provider.name} (${config.provider.model})`,
187
+ );
188
+ console.log(
189
+ `Found: ${humanFiltered.length} .human, ${result.strict.length} .strict.human, ` +
190
+ `${result.ignoredCount} ignored${result.secrets ? ", secrets.human present" : ""}`,
191
+ );
192
+ if (missingStrict.length > 0) {
193
+ console.log(`Would generate strict IR for ${missingStrict.length} file(s):`);
194
+ for (const f of missingStrict) console.log(` ${f.relPath} -> ${f.strictSibling}`);
195
+ }
196
+ console.log(
197
+ "\nNote: human -> strict and strict -> code generation are not implemented yet.",
198
+ );
199
+ return 0;
200
+ }
201
+
202
+ function toPosixRel(root: string, file: string): string {
203
+ const abs = resolve(root, file);
204
+ return abs.slice(root.length + 1).split(/[\\/]/).join("/");
205
+ }
206
+
207
+ // Only run when invoked as a script (not when imported by tests).
208
+ if (
209
+ import.meta.url === `file://${process.argv[1]}` ||
210
+ import.meta.filename === process.argv[1]
211
+ ) {
212
+ run(process.argv.slice(2))
213
+ .then((code) => process.exit(code))
214
+ .catch((err) => {
215
+ console.error(err instanceof Error ? err.stack ?? err.message : String(err));
216
+ process.exit(1);
217
+ });
218
+ }
package/src/config.ts ADDED
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Structured config loader.
3
+ *
4
+ * Config is deterministic JSON (`human-to-code.config.json`), never parsed by
5
+ * an LLM — see plan §1.7 / §2. A hand-rolled validator keeps the tool
6
+ * dependency-free and gives precise error messages.
7
+ */
8
+
9
+ import { readFile } from "node:fs/promises";
10
+ import { existsSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import type {
13
+ Config,
14
+ ProviderConfig,
15
+ ProviderName,
16
+ TargetLanguage,
17
+ } from "./types.ts";
18
+
19
+ export const CONFIG_FILENAME = "human-to-code.config.json";
20
+
21
+ const LANGUAGES: readonly TargetLanguage[] = [
22
+ "typescript",
23
+ "javascript",
24
+ "python",
25
+ ];
26
+
27
+ const PROVIDERS: readonly ProviderName[] = [
28
+ "openai",
29
+ "anthropic",
30
+ "ollama",
31
+ "grok",
32
+ "gemini",
33
+ ];
34
+
35
+ /**
36
+ * Current, non-dated default model per provider. The Anthropic default is the
37
+ * current flagship — the README's `claude-3-5-sonnet-20241022` is retired.
38
+ */
39
+ const DEFAULT_MODEL: Record<ProviderName, string> = {
40
+ anthropic: "claude-opus-4-8",
41
+ openai: "gpt-4o",
42
+ ollama: "llama3.2",
43
+ grok: "grok-4.1",
44
+ gemini: "gemini-2.5-pro",
45
+ };
46
+
47
+ /** The current default model id for a provider. */
48
+ export function defaultModelFor(name: ProviderName): string {
49
+ return DEFAULT_MODEL[name];
50
+ }
51
+
52
+ export const DEFAULT_CONFIG: Config = {
53
+ language: "typescript",
54
+ filesToIgnore: ["node_modules", ".git", "dist"],
55
+ allowNonHumanFiles: false,
56
+ provider: {
57
+ name: "anthropic",
58
+ model: DEFAULT_MODEL.anthropic,
59
+ },
60
+ };
61
+
62
+ /** Thrown for any malformed config; message is safe to show the user. */
63
+ export class ConfigError extends Error {}
64
+
65
+ function isObject(v: unknown): v is Record<string, unknown> {
66
+ return typeof v === "object" && v !== null && !Array.isArray(v);
67
+ }
68
+
69
+ function validateProvider(raw: unknown): ProviderConfig {
70
+ if (!isObject(raw)) {
71
+ throw new ConfigError("`provider` must be an object.");
72
+ }
73
+ const name = raw.name;
74
+ if (typeof name !== "string" || !PROVIDERS.includes(name as ProviderName)) {
75
+ throw new ConfigError(
76
+ `\`provider.name\` must be one of: ${PROVIDERS.join(", ")}.`,
77
+ );
78
+ }
79
+ const providerName = name as ProviderName;
80
+
81
+ let model = raw.model;
82
+ if (model === undefined) {
83
+ model = DEFAULT_MODEL[providerName];
84
+ } else if (typeof model !== "string" || model.length === 0) {
85
+ throw new ConfigError("`provider.model` must be a non-empty string.");
86
+ }
87
+
88
+ const provider: ProviderConfig = { name: providerName, model: model as string };
89
+
90
+ if (raw.baseUrl !== undefined) {
91
+ if (typeof raw.baseUrl !== "string") {
92
+ throw new ConfigError("`provider.baseUrl` must be a string.");
93
+ }
94
+ // Enforce TLS — a bad base URL can serve poisoned strict (plan §3.7).
95
+ if (!raw.baseUrl.startsWith("https://")) {
96
+ throw new ConfigError("`provider.baseUrl` must use https://.");
97
+ }
98
+ provider.baseUrl = raw.baseUrl;
99
+ }
100
+
101
+ return provider;
102
+ }
103
+
104
+ /** Validate an already-parsed object into a fully-defaulted Config. */
105
+ export function validateConfig(raw: unknown): Config {
106
+ if (!isObject(raw)) {
107
+ throw new ConfigError("Config root must be a JSON object.");
108
+ }
109
+
110
+ const config: Config = {
111
+ ...DEFAULT_CONFIG,
112
+ provider: { ...DEFAULT_CONFIG.provider },
113
+ };
114
+
115
+ if (raw.language !== undefined) {
116
+ if (
117
+ typeof raw.language !== "string" ||
118
+ !LANGUAGES.includes(raw.language as TargetLanguage)
119
+ ) {
120
+ throw new ConfigError(
121
+ `\`language\` must be one of: ${LANGUAGES.join(", ")}.`,
122
+ );
123
+ }
124
+ config.language = raw.language as TargetLanguage;
125
+ }
126
+
127
+ if (raw.filesToIgnore !== undefined) {
128
+ if (
129
+ !Array.isArray(raw.filesToIgnore) ||
130
+ !raw.filesToIgnore.every((f) => typeof f === "string")
131
+ ) {
132
+ throw new ConfigError("`filesToIgnore` must be an array of strings.");
133
+ }
134
+ config.filesToIgnore = raw.filesToIgnore as string[];
135
+ }
136
+
137
+ if (raw.allowNonHumanFiles !== undefined) {
138
+ if (typeof raw.allowNonHumanFiles !== "boolean") {
139
+ throw new ConfigError("`allowNonHumanFiles` must be a boolean.");
140
+ }
141
+ config.allowNonHumanFiles = raw.allowNonHumanFiles;
142
+ }
143
+
144
+ if (raw.provider !== undefined) {
145
+ config.provider = validateProvider(raw.provider);
146
+ }
147
+
148
+ return config;
149
+ }
150
+
151
+ /**
152
+ * Load config from `<root>/human-to-code.config.json`. Returns the defaults
153
+ * (with a flag) when no config file exists so first-run works with zero setup.
154
+ */
155
+ export async function loadConfig(
156
+ root: string,
157
+ ): Promise<{ config: Config; fromFile: boolean }> {
158
+ const path = join(root, CONFIG_FILENAME);
159
+ if (!existsSync(path)) {
160
+ return {
161
+ config: { ...DEFAULT_CONFIG, provider: { ...DEFAULT_CONFIG.provider } },
162
+ fromFile: false,
163
+ };
164
+ }
165
+
166
+ let text: string;
167
+ try {
168
+ text = await readFile(path, "utf8");
169
+ } catch (err) {
170
+ throw new ConfigError(`Could not read ${CONFIG_FILENAME}: ${String(err)}`);
171
+ }
172
+
173
+ let parsed: unknown;
174
+ try {
175
+ parsed = JSON.parse(text);
176
+ } catch (err) {
177
+ throw new ConfigError(`${CONFIG_FILENAME} is not valid JSON: ${String(err)}`);
178
+ }
179
+
180
+ return { config: validateConfig(parsed), fromFile: true };
181
+ }
182
+
183
+ /** Serialize the default config for `--init`. */
184
+ export function defaultConfigJson(): string {
185
+ return JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n";
186
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * File discovery for the deterministic core.
3
+ *
4
+ * Responsibilities (plan §3.3–3.4):
5
+ * - Walk the project, classifying .human / .strict.human / config / secrets.
6
+ * - Apply a name-based ignore denylist + best-effort .gitignore.
7
+ * - Never follow symlinks (traversal safety).
8
+ * - Provide a git-tracked-secrets check so the CLI can refuse to run.
9
+ *
10
+ * Discovery is side-effect free; the CLI decides what to do with the result.
11
+ */
12
+
13
+ import { readdir } from "node:fs/promises";
14
+ import { spawnSync } from "node:child_process";
15
+ import { relative, resolve, sep } from "node:path";
16
+ import { CONFIG_FILENAME } from "./config.ts";
17
+ import type { DiscoveryResult, SourceFile, SourceKind } from "./types.ts";
18
+
19
+ const SECRETS_FILENAME = "secrets.human";
20
+ const HUMAN_CONFIG_FILENAME = "config.human";
21
+ const STRICT_SUFFIX = ".strict.human";
22
+ const HUMAN_SUFFIX = ".human";
23
+
24
+ /** Names always skipped regardless of config. */
25
+ const ALWAYS_IGNORE = new Set([".git", "node_modules", CONFIG_FILENAME]);
26
+
27
+ function toPosix(p: string): string {
28
+ return sep === "/" ? p : p.split(sep).join("/");
29
+ }
30
+
31
+ function classify(basename: string): SourceKind | null {
32
+ if (basename === SECRETS_FILENAME) return "secrets";
33
+ if (basename === HUMAN_CONFIG_FILENAME) return "config";
34
+ if (basename.endsWith(STRICT_SUFFIX)) return "strict";
35
+ // A plain .human file that is not one of the special files above.
36
+ if (basename.endsWith(HUMAN_SUFFIX)) return "human";
37
+ return null;
38
+ }
39
+
40
+ function strictSiblingOf(relPath: string): string {
41
+ // foo.human -> foo.strict.human
42
+ return relPath.slice(0, -HUMAN_SUFFIX.length) + STRICT_SUFFIX;
43
+ }
44
+
45
+ /**
46
+ * Recursively collect candidate files. Directories/files whose basename is in
47
+ * `ignore` are skipped. Symlinks are never followed.
48
+ */
49
+ async function walk(
50
+ root: string,
51
+ dir: string,
52
+ ignore: Set<string>,
53
+ out: SourceFile[],
54
+ counters: { ignored: number },
55
+ ): Promise<void> {
56
+ let entries;
57
+ try {
58
+ entries = await readdir(dir, { withFileTypes: true });
59
+ } catch {
60
+ return;
61
+ }
62
+
63
+ for (const entry of entries) {
64
+ const abs = resolve(dir, entry.name);
65
+
66
+ if (entry.isSymbolicLink()) {
67
+ counters.ignored++;
68
+ continue; // traversal safety — never follow symlinks
69
+ }
70
+
71
+ if (ignore.has(entry.name)) {
72
+ counters.ignored++;
73
+ continue;
74
+ }
75
+
76
+ if (entry.isDirectory()) {
77
+ await walk(root, abs, ignore, out, counters);
78
+ continue;
79
+ }
80
+
81
+ if (!entry.isFile()) continue;
82
+
83
+ const kind = classify(entry.name);
84
+ if (kind === null) {
85
+ // Not a file we track; not counted as "ignored" for reporting.
86
+ continue;
87
+ }
88
+
89
+ const relPath = toPosix(relative(root, abs));
90
+ const file: SourceFile = { absPath: abs, relPath, kind };
91
+ if (kind === "human") {
92
+ file.strictSibling = strictSiblingOf(relPath);
93
+ }
94
+ out.push(file);
95
+ }
96
+ }
97
+
98
+ /** Return the subset of relPaths that git considers ignored (best effort). */
99
+ function gitIgnored(root: string, relPaths: string[]): Set<string> {
100
+ if (relPaths.length === 0) return new Set();
101
+ const inRepo = spawnSync(
102
+ "git",
103
+ ["-C", root, "rev-parse", "--is-inside-work-tree"],
104
+ { encoding: "utf8" },
105
+ );
106
+ if (inRepo.status !== 0 || inRepo.stdout.trim() !== "true") return new Set();
107
+
108
+ const res = spawnSync("git", ["-C", root, "check-ignore", "--stdin"], {
109
+ input: relPaths.join("\n"),
110
+ encoding: "utf8",
111
+ });
112
+ // Exit 0 => some ignored, 1 => none ignored, other => error (treat as none).
113
+ if (res.status !== 0 && res.status !== 1) return new Set();
114
+ return new Set(
115
+ res.stdout
116
+ .split("\n")
117
+ .map((l) => l.trim())
118
+ .filter(Boolean),
119
+ );
120
+ }
121
+
122
+ /**
123
+ * Discover and classify all source files under `rootInput`.
124
+ *
125
+ * @param rootInput directory to scan
126
+ * @param extraIgnore config.filesToIgnore (name-based denylist)
127
+ */
128
+ export async function discover(
129
+ rootInput: string,
130
+ extraIgnore: string[] = [],
131
+ ): Promise<DiscoveryResult> {
132
+ const root = resolve(rootInput);
133
+ const ignore = new Set<string>([...ALWAYS_IGNORE, ...extraIgnore]);
134
+
135
+ const found: SourceFile[] = [];
136
+ const counters = { ignored: 0 };
137
+ await walk(root, root, ignore, found, counters);
138
+
139
+ // Best-effort .gitignore filtering on top of the name denylist.
140
+ const ignored = gitIgnored(
141
+ root,
142
+ found.map((f) => f.relPath),
143
+ );
144
+ const kept = found.filter((f) => {
145
+ // Secrets are always surfaced (so we can enforce safety), even if ignored.
146
+ if (f.kind === "secrets") return true;
147
+ if (ignored.has(f.relPath)) {
148
+ counters.ignored++;
149
+ return false;
150
+ }
151
+ return true;
152
+ });
153
+
154
+ const result: DiscoveryResult = {
155
+ root,
156
+ human: kept.filter((f) => f.kind === "human"),
157
+ strict: kept.filter((f) => f.kind === "strict"),
158
+ ignoredCount: counters.ignored,
159
+ };
160
+ const secrets = kept.find((f) => f.kind === "secrets");
161
+ if (secrets) result.secrets = secrets;
162
+ return result;
163
+ }
164
+
165
+ /**
166
+ * Refuse to run if `secrets.human` is tracked by git — a committed secrets file
167
+ * is the mistake we most want to prevent (plan §3.3). No-op outside a git repo.
168
+ * @returns an error message if unsafe, otherwise null.
169
+ */
170
+ export function secretsTrackedError(secret: SourceFile): string | null {
171
+ if (secret.kind !== "secrets") return null;
172
+ const root = resolve(secret.absPath, "..");
173
+ const res = spawnSync(
174
+ "git",
175
+ ["-C", root, "ls-files", "--error-unmatch", secret.relPath],
176
+ { encoding: "utf8" },
177
+ );
178
+ if (res.status === 0) {
179
+ return (
180
+ `Refusing to run: ${secret.relPath} is tracked by git. ` +
181
+ `Remove it with \`git rm --cached ${secret.relPath}\` and add it to .gitignore. ` +
182
+ `Prefer environment variables or your OS keychain for provider credentials.`
183
+ );
184
+ }
185
+ return null;
186
+ }
package/src/index.ts ADDED
@@ -0,0 +1,22 @@
1
+ /** Public API surface for programmatic use. */
2
+
3
+ export type {
4
+ Config,
5
+ ProviderConfig,
6
+ ProviderName,
7
+ TargetLanguage,
8
+ SourceFile,
9
+ SourceKind,
10
+ DiscoveryResult,
11
+ } from "./types.ts";
12
+
13
+ export {
14
+ loadConfig,
15
+ validateConfig,
16
+ defaultConfigJson,
17
+ DEFAULT_CONFIG,
18
+ CONFIG_FILENAME,
19
+ ConfigError,
20
+ } from "./config.ts";
21
+
22
+ export { discover, secretsTrackedError } from "./discovery.ts";
package/src/types.ts ADDED
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Shared types for human-to-code.
3
+ *
4
+ * The pipeline is: .human --(LLM)--> .strict.human --(deterministic)--> code
5
+ * These types describe the deterministic core (config + discovery) only; the
6
+ * strict DSL and code generators are added in later build steps.
7
+ */
8
+
9
+ export type TargetLanguage = "typescript" | "javascript" | "python";
10
+
11
+ export type ProviderName =
12
+ | "openai"
13
+ | "anthropic"
14
+ | "ollama"
15
+ | "grok"
16
+ | "gemini";
17
+
18
+ export interface ProviderConfig {
19
+ /** Which LLM provider drives the human -> strict step. */
20
+ name: ProviderName;
21
+ /** Model id. Configurable; defaults are current, not dated snapshots. */
22
+ model: string;
23
+ /** Optional override endpoint (e.g. Ollama Cloud). Must be https. */
24
+ baseUrl?: string;
25
+ }
26
+
27
+ export interface Config {
28
+ /** Target language for the deterministic code generator. */
29
+ language: TargetLanguage;
30
+ /** Glob-free directory/file names to skip during discovery (a denylist). */
31
+ filesToIgnore: string[];
32
+ /**
33
+ * If true, non-.human files carrying `@Human:` directives may also be
34
+ * processed. Off by default — it widens the surface area (see plan §1.6).
35
+ */
36
+ allowNonHumanFiles: boolean;
37
+ /** Provider used only for the human -> strict step. */
38
+ provider: ProviderConfig;
39
+ }
40
+
41
+ /** Classification of a source file found during discovery. */
42
+ export type SourceKind =
43
+ /** A hand-written or generated strict IR file: `foo.strict.human`. */
44
+ | "strict"
45
+ /** A natural-language source file: `foo.human` (not `.strict.human`). */
46
+ | "human"
47
+ /** Reserved special files, never compiled and never sent to a provider. */
48
+ | "config"
49
+ | "secrets";
50
+
51
+ export interface SourceFile {
52
+ /** Absolute path. */
53
+ absPath: string;
54
+ /** Path relative to the discovery root, using forward slashes. */
55
+ relPath: string;
56
+ kind: SourceKind;
57
+ /**
58
+ * For a `.human` file, the sibling `.strict.human` path that would take
59
+ * precedence if it exists. Undefined for non-human kinds.
60
+ */
61
+ strictSibling?: string;
62
+ }
63
+
64
+ export interface DiscoveryResult {
65
+ root: string;
66
+ /** Natural-language sources eligible for the human -> strict step. */
67
+ human: SourceFile[];
68
+ /** Strict IR sources (hand-written ones take precedence over generated). */
69
+ strict: SourceFile[];
70
+ /** The secrets file, if present. Never compiled, never sent to a provider. */
71
+ secrets?: SourceFile;
72
+ /** Files skipped by ignore rules, for reporting under --dry-run. */
73
+ ignoredCount: number;
74
+ }
@@ -0,0 +1,97 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtemp, writeFile, rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import {
7
+ validateConfig,
8
+ loadConfig,
9
+ ConfigError,
10
+ DEFAULT_CONFIG,
11
+ CONFIG_FILENAME,
12
+ } from "../src/config.ts";
13
+
14
+ test("validateConfig fills defaults from an empty object", () => {
15
+ const c = validateConfig({});
16
+ assert.equal(c.language, "typescript");
17
+ assert.equal(c.provider.name, "anthropic");
18
+ assert.equal(c.provider.model, "claude-opus-4-8");
19
+ assert.equal(c.allowNonHumanFiles, false);
20
+ });
21
+
22
+ test("validateConfig accepts a valid language", () => {
23
+ assert.equal(validateConfig({ language: "python" }).language, "python");
24
+ });
25
+
26
+ test("validateConfig rejects an unknown language", () => {
27
+ assert.throws(() => validateConfig({ language: "rust" }), ConfigError);
28
+ });
29
+
30
+ test("provider model defaults per provider", () => {
31
+ const c = validateConfig({ provider: { name: "openai" } });
32
+ assert.equal(c.provider.model, "gpt-4o");
33
+ });
34
+
35
+ test("validateConfig rejects an unknown provider", () => {
36
+ assert.throws(
37
+ () => validateConfig({ provider: { name: "cohere" } }),
38
+ ConfigError,
39
+ );
40
+ });
41
+
42
+ test("baseUrl must be https", () => {
43
+ assert.throws(
44
+ () =>
45
+ validateConfig({
46
+ provider: { name: "ollama", baseUrl: "http://ollama.com" },
47
+ }),
48
+ ConfigError,
49
+ );
50
+ const ok = validateConfig({
51
+ provider: { name: "ollama", baseUrl: "https://ollama.com" },
52
+ });
53
+ assert.equal(ok.provider.baseUrl, "https://ollama.com");
54
+ });
55
+
56
+ test("validateConfig rejects non-object root", () => {
57
+ assert.throws(() => validateConfig([]), ConfigError);
58
+ assert.throws(() => validateConfig("nope"), ConfigError);
59
+ });
60
+
61
+ test("loadConfig returns defaults when no file exists", async () => {
62
+ const dir = await mkdtemp(join(tmpdir(), "h2c-cfg-"));
63
+ try {
64
+ const { config, fromFile } = await loadConfig(dir);
65
+ assert.equal(fromFile, false);
66
+ assert.deepEqual(config, DEFAULT_CONFIG);
67
+ } finally {
68
+ await rm(dir, { recursive: true, force: true });
69
+ }
70
+ });
71
+
72
+ test("loadConfig reads and validates a config file", async () => {
73
+ const dir = await mkdtemp(join(tmpdir(), "h2c-cfg-"));
74
+ try {
75
+ await writeFile(
76
+ join(dir, CONFIG_FILENAME),
77
+ JSON.stringify({ language: "javascript", provider: { name: "gemini" } }),
78
+ );
79
+ const { config, fromFile } = await loadConfig(dir);
80
+ assert.equal(fromFile, true);
81
+ assert.equal(config.language, "javascript");
82
+ assert.equal(config.provider.name, "gemini");
83
+ assert.equal(config.provider.model, "gemini-2.5-pro");
84
+ } finally {
85
+ await rm(dir, { recursive: true, force: true });
86
+ }
87
+ });
88
+
89
+ test("loadConfig surfaces invalid JSON as ConfigError", async () => {
90
+ const dir = await mkdtemp(join(tmpdir(), "h2c-cfg-"));
91
+ try {
92
+ await writeFile(join(dir, CONFIG_FILENAME), "{ not json ");
93
+ await assert.rejects(() => loadConfig(dir), ConfigError);
94
+ } finally {
95
+ await rm(dir, { recursive: true, force: true });
96
+ }
97
+ });
@@ -0,0 +1,91 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtemp, mkdir, writeFile, symlink, rm } from "node:fs/promises";
4
+ import { spawnSync } from "node:child_process";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { discover, secretsTrackedError } from "../src/discovery.ts";
8
+
9
+ async function makeFixture(): Promise<string> {
10
+ const root = await mkdtemp(join(tmpdir(), "h2c-disc-"));
11
+ await writeFile(join(root, "a.human"), "print hello");
12
+ await writeFile(join(root, "a.strict.human"), "fn printHello");
13
+ await writeFile(join(root, "b.human"), "print bye"); // no strict sibling
14
+ await writeFile(join(root, "secrets.human"), "API Key: sk-x");
15
+ await writeFile(join(root, "config.human"), "Language: typescript");
16
+ await writeFile(join(root, "human-to-code.config.json"), "{}");
17
+ await mkdir(join(root, "node_modules"));
18
+ await writeFile(join(root, "node_modules", "dep.human"), "ignored");
19
+ await mkdir(join(root, "sub"));
20
+ await writeFile(join(root, "sub", "c.human"), "nested");
21
+ try {
22
+ await symlink(join(root, "sub"), join(root, "linkdir"), "dir");
23
+ } catch {
24
+ // symlinks may be unavailable; the rest of the assertions still hold.
25
+ }
26
+ return root;
27
+ }
28
+
29
+ test("discover classifies files and applies ignore rules", async () => {
30
+ const root = await makeFixture();
31
+ try {
32
+ const r = await discover(root, ["node_modules", ".git", "dist"]);
33
+
34
+ const humanPaths = r.human.map((f) => f.relPath).sort();
35
+ assert.deepEqual(humanPaths, ["a.human", "b.human", "sub/c.human"]);
36
+
37
+ assert.deepEqual(
38
+ r.strict.map((f) => f.relPath),
39
+ ["a.strict.human"],
40
+ );
41
+
42
+ // node_modules/dep.human is ignored.
43
+ assert.ok(!humanPaths.includes("node_modules/dep.human"));
44
+
45
+ // The symlinked dir must not be followed (no duplicate c.human).
46
+ assert.ok(!humanPaths.includes("linkdir/c.human"));
47
+
48
+ // secrets.human is surfaced separately, never as a human source.
49
+ assert.ok(r.secrets);
50
+ assert.equal(r.secrets?.relPath, "secrets.human");
51
+
52
+ // strictSibling wiring.
53
+ const a = r.human.find((f) => f.relPath === "a.human");
54
+ assert.equal(a?.strictSibling, "a.strict.human");
55
+ const b = r.human.find((f) => f.relPath === "b.human");
56
+ assert.equal(b?.strictSibling, "b.strict.human");
57
+ } finally {
58
+ await rm(root, { recursive: true, force: true });
59
+ }
60
+ });
61
+
62
+ test("secretsTrackedError returns null when not in a git repo", async () => {
63
+ const root = await makeFixture();
64
+ try {
65
+ const r = await discover(root, ["node_modules"]);
66
+ assert.ok(r.secrets);
67
+ assert.equal(secretsTrackedError(r.secrets!), null);
68
+ } finally {
69
+ await rm(root, { recursive: true, force: true });
70
+ }
71
+ });
72
+
73
+ test("secretsTrackedError refuses when secrets.human is git-tracked", async () => {
74
+ const root = await makeFixture();
75
+ try {
76
+ assert.equal(
77
+ spawnSync("git", ["-C", root, "init", "-q"]).status,
78
+ 0,
79
+ "git init failed",
80
+ );
81
+ // Staging is enough for `git ls-files` to consider it tracked.
82
+ spawnSync("git", ["-C", root, "add", "secrets.human"]);
83
+
84
+ const r = await discover(root, ["node_modules"]);
85
+ assert.ok(r.secrets);
86
+ const msg = secretsTrackedError(r.secrets!);
87
+ assert.ok(msg && msg.includes("Refusing to run"));
88
+ } finally {
89
+ await rm(root, { recursive: true, force: true });
90
+ }
91
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "esnext",
4
+ "module": "nodenext",
5
+ "moduleResolution": "nodenext",
6
+ "lib": ["esnext"],
7
+ "types": ["node"],
8
+ "strict": true,
9
+ "noUncheckedIndexedAccess": true,
10
+ "verbatimModuleSyntax": true,
11
+ "allowImportingTsExtensions": true,
12
+ "noEmit": true,
13
+ "skipLibCheck": true,
14
+ "forceConsistentCasingInFileNames": true
15
+ },
16
+ "include": ["src", "test"]
17
+ }