Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/code-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,3 +187,9 @@ coordinates to the public npm registry. Enable it explicitly with
deduplicates registry calls per `(name, version)`.

[slop-paper]: https://arxiv.org/abs/2406.10279

For a Node frontend and Rust backend sharing one HTTP path namespace,
`review --link-http frontend:backend` adds bounded, literal HTTP candidate context
only for that review. Existing feature records and default review behavior are
unchanged. See [Optional HTTP relations](feature-mapping.md#optional-http-relations)
for the root-pair assertion, supported syntax, ambiguity rules, and limits.
51 changes: 51 additions & 0 deletions docs/feature-mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,3 +195,54 @@ Known gaps:
or runtime route conventions
- no import graph expansion beyond nearby tests yet
- agent mapping depends on provider quality and validates paths but not semantic intent

## Optional HTTP relations

For a Node/TypeScript frontend and Rust backend that share one HTTP path
namespace, opt in with explicit, non-overlapping repository-relative roots:

```sh
clawpatch map --link-http frontend:backend --json
clawpatch review --link-http frontend:backend --limit 3
```

The root pair asserts which client and service belong together. Clawpatch does
not discover deployment origins, proxy rules, or service topology. The output
contains **candidate** HTTP relations, not proof of runtime connectivity.
Verify routing before relying on a relation in a finding.

The first version matches unescaped literal `fetch("/path")` (GET) and
`fetch("/path", { method: "POST" })` calls to Rust `#[get("/path")]`,
`#[post("/path")]`, `put`, `patch`, `delete`, `head`, or `options` attributes.
Caller scanning supports `.js`, `.ts`, `.mjs`, `.cjs`, `.mts`, and `.cts`; JSX/TSX
files are skipped. Matching uses the standard HTTP method and exact literal path. Additional fetch options, computed values,
template literals, escaped literals, whitespace in paths, query strings, fragments,
absolute URLs, parameters, wildcard paths,
Axios, and other handler syntaxes are unsupported. Comments and string contents
are skipped. JavaScript tokenization follows [js-tokens](https://github.com/lydell/js-tokens)
lexical coverage; files that exceed tokenizer limits are skipped. Actix-shaped `web::scope(...)` and any Rust `.mount(...)` call disable the pass
because their prefixes are unresolved, including mounts in helpers whose server
was constructed elsewhere. External prefixes and macro-generated
routes remain outside this heuristic; the supplied roots must use the same path
namespace. Multiple recognized handlers for the same method/path are ambiguous
and produce no link, even when declared in one file.

Mapping returns an `http` object containing `relations`, `omitted`, and
`skippedReason`; `--dry-run` returns it too. Each relation identifies the HTTP
method/path, caller and handler files/lines, and the features owning those files.
Default mapping output and stored feature slices remain unchanged. Reviews with
this flag recompute relations from current source, then append up to three
counterpart files to an ephemeral prompt copy after existing context. Existing
context-file and per-file prompt limits still apply, including omission reporting.
Review without the flag never adds HTTP context. Mapping alone does not enable
it for later reviews, fixes, revalidation, or `ci` runs.

The scan honors configured include/exclude filters and normal mapper directory
exclusions, skips symlinks, and links only files owned by active features. It
scans at most 500 source files, 256,000 bytes per file, and 8,000,000 bytes total;
exceeding a scan budget returns no relations with a reason rather than matching
against an incomplete inventory. Output is sorted by source path and declaration
order and limited to 200 relations; `omitted` counts links dropped by that output
limit. The three-file review context limit is applied independently for each
feature, so a broad co-owner cannot suppress context for a narrower feature. There is no graph storage
or feature schema migration.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"crabbox:warmup": "crabbox warmup"
},
"dependencies": {
"js-tokens": "^10.0.0",
"proper-lockfile": "^4.1.2",
"zod": "^4.5.4"
},
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 16 additions & 3 deletions scripts/package-smoke.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { dirname, isAbsolute, join } from "node:path";
Expand Down Expand Up @@ -255,7 +255,7 @@ function runtimeDependencyPaths(rootPath = root) {
function collect(packageJsonPath, packageRequire) {
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
for (const name of runtimeDependencyNames(packageJson)) {
const dependencyPackageJson = packageRequire.resolve(`${name}/package.json`);
const dependencyPackageJson = runtimeDependencyManifest(packageRequire, name);
const dependencyPath = dirname(dependencyPackageJson);
if (dependencyPaths.has(dependencyPath)) {
continue;
Expand All @@ -270,6 +270,19 @@ function runtimeDependencyPaths(rootPath = root) {
return [...dependencyPaths.values()];
}

function runtimeDependencyManifest(packageRequire, name) {
let directory = dirname(packageRequire.resolve(name));
for (;;) {
const candidate = join(directory, "package.json");
if (existsSync(candidate) && JSON.parse(readFileSync(candidate, "utf8")).name === name) {
return candidate;
}
const parent = dirname(directory);
if (parent === directory) throw new Error(`package metadata not found for ${name}`);
directory = parent;
}
}

function runtimeDependencyNames(packageJson) {
return Object.keys(packageJson.dependencies ?? {});
}
Expand Down Expand Up @@ -329,7 +342,7 @@ function verifyRuntimeDependencies(context) {
for (const name of runtimeDependencyNames(packageJson)) {
run(context, "node", [
"-e",
"require.resolve(`${process.argv[1]}/package.json`, { paths: [process.argv[2]] })",
"require.resolve(process.argv[1], { paths: [process.argv[2]] })",
name,
packageRoot,
]);
Expand Down
8 changes: 8 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { nowIso, writeJson } from "./fs.js";
import { discoverGit } from "./git.js";
import { mapWithSource } from "./agent-mapper.js";
import { mapFeatures } from "./mapper.js";
import { findHttpRelations } from "./http-relations.js";
import { emitProgress } from "./progress.js";
import { providerByName } from "./provider.js";
import {
Expand Down Expand Up @@ -112,6 +113,11 @@ export async function mapCommand(
emitProgress(context, "map", event, fields);
},
});
const linkHttp = stringFlag(flags, "linkHttp");
const http =
linkHttp === undefined
? {}
: { http: await findHttpRelations(loaded.root, result.features, linkHttp, filters) };
const activeFeatureIds = new Set(result.features.map((feature) => feature.featureId));
if (flags["dryRun"] === true) {
emitProgress(context, "map", "done", {
Expand All @@ -120,6 +126,7 @@ export async function mapCommand(
elapsed: `${Math.round((Date.now() - started) / 1000)}s`,
});
return {
...http,
dryRun: true,
features: result.features.length,
new: result.created,
Expand Down Expand Up @@ -152,6 +159,7 @@ export async function mapCommand(
elapsed: `${Math.round((Date.now() - started) / 1000)}s`,
});
return {
...http,
features: result.features.length,
new: result.created,
changed: result.changed,
Expand Down
19 changes: 18 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
} from "./app.js";
import { ClawpatchError } from "./errors.js";
import { GlobalOptions } from "./config.js";
import { httpRoots } from "./http-relations.js";

const moduleRequire = createRequire(import.meta.url);

Expand Down Expand Up @@ -112,6 +113,7 @@ export function parseArgs(argv: string[]): ParsedArgs {
command = "status";
}
validateCommandFlags(command, flags);
if (typeof flags["linkHttp"] === "string") httpRoots(flags["linkHttp"]);
validateCommandRequirements(command, flags);
return { command, flags, global, help: false, version: false };
}
Expand All @@ -131,7 +133,15 @@ type CommandSpec = {
const commandSpecs = {
init: { flags: ["force"], usage: ["clawpatch init [flags]"], run: initCommand },
map: {
flags: ["dryRun", "source", "provider", "model", "reasoningEffort", "skipGitRepoCheck"],
flags: [
"dryRun",
"source",
"provider",
"model",
"reasoningEffort",
"skipGitRepoCheck",
"linkHttp",
],
usage: ["clawpatch map [flags]"],
run: mapCommand,
},
Expand All @@ -140,6 +150,7 @@ const commandSpecs = {
flags: [
"feature",
"featureList",
"linkHttp",
"project",
"limit",
"since",
Expand Down Expand Up @@ -316,6 +327,12 @@ const optionSpecs: Record<string, OptionSpec> = {
target: "command",
help: " --rate-limit-per-minute <n> cap provider calls per 60s window (env: CLAWPATCH_RPM)",
},
"link-http": {
name: "linkHttp",
kind: "value",
target: "command",
help: " --link-http <caller:backend> opt-in HTTP candidate context between directory roots",
},
source: {
name: "source",
kind: "value",
Expand Down
Loading
Loading