Skip to content
Draft
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
78 changes: 73 additions & 5 deletions src/drift/checkers/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,77 @@ import { readFileSync, existsSync } from "node:fs";
import { resolve } from "node:path";
import type { Claim, DriftIssue } from "../../types.js";

/**
* First-token verbs that yarn/pnpm treat as their own commands. Bare
* `yarn install` / `pnpm add` must not be looked up as package.json scripts.
* `run` is handled separately so `yarn run <script>` still checks scripts.
*/
const YARN_PNPM_BUILTINS = new Set([
"add",
"audit",
"bin",
"cache",
"ci",
"config",
"create",
"dedupe",
"deploy",
"dlx",
"doctor",
"env",
"exec",
"explain",
"fetch",
"focus",
"global",
"help",
"import",
"init",
"install",
"link",
"list",
"login",
"logout",
"ls",
"outdated",
"pack",
"patch",
"patch-commit",
"plugin",
"prune",
"publish",
"rebuild",
"recursive",
"remove",
"root",
"set",
"setup",
"store",
"uninstall",
"unlink",
"unset",
"unplug",
"up",
"update",
"upgrade",
"version",
"why",
"workspace",
"workspaces",
"i",
"rm",
]);

/** Resolve a package-manager invocation to a script name, or null if it is not a script lookup. */
function packageManagerScriptName(cmd: string): string | null {
const scopedRun = cmd.match(/^(?:npm|yarn|pnpm|bun)\s+run\s+(\S+)/);
if (scopedRun) return scopedRun[1];

const bare = cmd.match(/^(?:yarn|pnpm)\s+(\S+)/);
if (!bare) return null;
return YARN_PNPM_BUILTINS.has(bare[1]) ? null : bare[1];
}

/** Check that claimed npm/yarn/make commands actually exist */
export function checkCommands(
claims: Claim[],
Expand All @@ -19,11 +90,8 @@ export function checkCommands(
const cmd = claim.value.trim();

// npm run <script> / yarn <script> / pnpm <script>
const npmMatch = cmd.match(
/^(?:npm\s+run|yarn|pnpm|bun\s+run)\s+(\S+)/
);
if (npmMatch) {
const script = npmMatch[1];
const script = packageManagerScriptName(cmd);
if (script !== null) {
if (pkgScripts && !pkgScripts.has(script)) {
issues.push({
code: "DEAD_COMMAND",
Expand Down
21 changes: 21 additions & 0 deletions src/drift/checkers/index-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ import { readFileSync, existsSync } from "node:fs";
import { resolve, basename } from "node:path";
import { globSync } from "glob";
import type { DriftIssue } from "../../types.js";
import { extractFrontmatter } from "../../markdown.js";

const EDGE_TARGET_PATTERN = /(?:^|\/)patterns\/([^/]+\.md)$/;
/** INDEX.md lives in patterns/, so sibling edge targets omit the patterns/ prefix. */
const INDEX_SIBLING_TARGET = /^(?:\.\/)?([^/]+\.md)$/;

/** Cross-reference patterns/INDEX.md with actual pattern files */
export function checkIndexSync(projectRoot: string, scaffoldRoot: string): DriftIssue[] {
Expand Down Expand Up @@ -38,6 +43,22 @@ export function checkIndexSync(projectRoot: string, scaffoldRoot: string): Drift
referencedFiles.add(match[1]);
}

// Frontmatter edges are mex's canonical navigation, so a pattern reached
// only through an edge is not orphaned. Parsed from the content already
// read above rather than re-reading the file.
const edges = extractFrontmatter(rawContent)?.edges;
for (const edge of Array.isArray(edges) ? edges : []) {
const target = typeof edge?.target === "string" ? edge.target.replace(/#.*$/, "") : "";
if (!target) continue;
const fromPatterns = EDGE_TARGET_PATTERN.exec(target);
if (fromPatterns) {
referencedFiles.add(fromPatterns[1]);
continue;
}
const sibling = INDEX_SIBLING_TARGET.exec(target);
if (sibling) referencedFiles.add(basename(sibling[1]));
}

// Check: pattern files not in INDEX
for (const file of patternFiles) {
if (!referencedFiles.has(file)) {
Expand Down
58 changes: 58 additions & 0 deletions test/checkers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,39 @@ describe("checkCommands", () => {
const issues = checkCommands(claims, tmpDir);
expect(issues).toHaveLength(0);
});

it("does not treat yarn/pnpm builtins as missing scripts", () => {
writeFileSync(
join(tmpDir, "package.json"),
JSON.stringify({ scripts: { build: "tsc" } })
);
const claims = [
claim({ kind: "command", value: "pnpm install" }),
claim({ kind: "command", value: "yarn add" }),
claim({ kind: "command", value: "pnpm dlx" }),
claim({ kind: "command", value: "yarn ci" }),
];
expect(checkCommands(claims, tmpDir)).toHaveLength(0);
});

it("still reports a missing yarn/pnpm script", () => {
writeFileSync(
join(tmpDir, "package.json"),
JSON.stringify({ scripts: { build: "tsc" } })
);
const issues = checkCommands(
[
claim({ kind: "command", value: "pnpm lint" }),
claim({ kind: "command", value: "yarn run missing" }),
],
tmpDir
);
expect(issues).toHaveLength(2);
expect(issues.map((issue) => issue.message)).toEqual([
'Script "lint" not found in package.json scripts',
'Script "missing" not found in package.json scripts',
]);
});
});

// ── Dependency Checker ──
Expand Down Expand Up @@ -738,6 +771,31 @@ describe("checkIndexSync", () => {
const issues = checkIndexSync(tmpDir, tmpDir);
expect(issues).toHaveLength(0);
});

it("treats a frontmatter edge as an INDEX reference", () => {
mkdirSync(join(tmpDir, "patterns"), { recursive: true });
writeFileSync(
join(tmpDir, "patterns/INDEX.md"),
"---\nedges:\n - target: syntax-extractor-review.md\n---\n\n# Index\n"
);
writeFileSync(join(tmpDir, "patterns/syntax-extractor-review.md"), "# Review");
const issues = checkIndexSync(tmpDir, tmpDir);
expect(issues).toHaveLength(0);
});

it("still reports a pattern with no INDEX link or edge", () => {
mkdirSync(join(tmpDir, "patterns"), { recursive: true });
writeFileSync(
join(tmpDir, "patterns/INDEX.md"),
"---\nedges:\n - target: syntax-extractor-review.md\n---\n\n# Index\n"
);
writeFileSync(join(tmpDir, "patterns/syntax-extractor-review.md"), "# Review");
writeFileSync(join(tmpDir, "patterns/orphan.md"), "# Orphan");
const issues = checkIndexSync(tmpDir, tmpDir);
expect(issues).toHaveLength(1);
expect(issues[0].code).toBe("INDEX_MISSING_ENTRY");
expect(issues[0].message).toContain("patterns/orphan.md");
});
});

// ── Stale Pattern Checker ──
Expand Down