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
97 changes: 97 additions & 0 deletions lib/project-facts.js
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,101 @@
return signals;
}

// Directory-name conventions checked by getArchitectureSignals(). Kept as
// data, not inline logic, so the rule table stays legible against
// adr-architecture-pattern-signals.md's own table.
const MVC_DIR_NAMES = ["controllers", "models", "views"];
const LAYERED_DIR_NAME_GROUPS = [
["routes", "services", "repositories"],
["domain", "application", "infrastructure"],
];

/**
* Collect every directory name appearing anywhere in a
* `getProjectStructure()` result (including nested `children`,
* recursively) into a Set. Reused by getArchitectureSignals() instead of
* a second filesystem walk.
* @param {object[]} structure
* @returns {Set<string>}
*/
function collectDirectoryNamesFromStructure(structure) {
const names = new Set();
(structure || []).forEach(function (node) {
names.add(node.name);
if (node.children) {
collectDirectoryNamesFromStructure(node.children).forEach(function (n) { names.add(n); });
}
});
return names;
}

/**
* Architecture-pattern signals: independent, evidence-backed
* observations (never a single forced "the architecture is X" label --
* see adr-architecture-pattern-signals.md). Every rule is deterministic
* and reuses Phase 1 (`getProjectStructure`, `getWorkspacePackages`) and
* Phase 2 (`getFrameworkSignals`) output -- zero new filesystem calls.
* @param {string} rootDir
* @returns {{name: string, evidence: string}[]}
*/
function getArchitectureSignals(rootDir) {
const signals = [];
const pkg = readJsonSafe(path.join(rootDir, "package.json"), {});

if (pkg.bin && (typeof pkg.bin === "string" || (typeof pkg.bin === "object" && Object.keys(pkg.bin).length > 0))) {
const binNames = typeof pkg.bin === "string" ? [path.basename(rootDir)] : Object.keys(pkg.bin);
signals.push({ name: "CLI tool", evidence: "package.json \"bin\": " + binNames.join(", ") });
}

if (pkg.main || pkg.exports) {
signals.push({ name: "Publishable library", evidence: "package.json has a \"" + (pkg.exports ? "exports" : "main") + "\" field" });
}

const workspacePackages = getWorkspacePackages(rootDir);
if (workspacePackages.length > 0) {
signals.push({
name: "Monorepo (npm workspaces)",
evidence: workspacePackages.length + " workspace package(s): " + workspacePackages.map(function (p) { return p.name; }).join(", "),
});
}

const dependencyFrameworks = getFrameworkSignals(rootDir).filter(function (s) { return s.confidence === "dependency"; });
const dependencyNames = dependencyFrameworks.map(function (s) { return s.name; });

const backendFrameworks = dependencyNames.filter(function (n) { return n === "Express" || n === "NestJS"; });
if (backendFrameworks.length > 0) {
signals.push({ name: "Backend/API service", evidence: "framework dependency: " + backendFrameworks.join(", ") });
}

const frontendFrameworks = dependencyNames.filter(function (n) { return ["React", "Next.js", "Angular", "Vue"].indexOf(n) !== -1; });
if (frontendFrameworks.length > 0) {
signals.push({ name: "Frontend application", evidence: "framework dependency: " + frontendFrameworks.join(", ") });
}

const dirNames = collectDirectoryNamesFromStructure(getProjectStructure(rootDir));

const matchedMvc = MVC_DIR_NAMES.filter(function (n) { return dirNames.has(n); });
if (matchedMvc.length >= 2) {
signals.push({ name: "MVC-influenced layout", evidence: "directories present: " + matchedMvc.join(", ") });
}

// One signal, not one per group: the rule is "group A matches OR
// group B matches", not two independent observations. If both
// groups happen to match, report both as evidence on the single
// signal rather than emitting a duplicate-named entry.
const matchedLayeredGroups = LAYERED_DIR_NAME_GROUPS
.map(function (group) { return group.filter(function (n) { return dirNames.has(n); }); })
.filter(function (matched) { return matched.length >= 2; });
if (matchedLayeredGroups.length > 0) {
signals.push({
name: "Layered/service-oriented layout",
evidence: "directories present: " + matchedLayeredGroups.map(function (g) { return g.join(", "); }).join(" / "),
});
}

return signals;
}

Check failure

Code scanning / code-multivitals

High Halstead volume Error

getArchitectureSignals: halsteadVolume = 1552.92 (threshold: 1000)

Check warning

Code scanning / code-multivitals

High cyclomatic complexity Warning

getArchitectureSignals: cyclomaticComplexity = 15 (threshold: 20)

Check warning

Code scanning / code-multivitals

Function too long Warning

getArchitectureSignals: functionLength = 57 (threshold: 80)

Check warning

Code scanning / code-multivitals

Low Maintainability Index Warning

getArchitectureSignals: Maintainability Index = 37.3 (warn < 65, error < 20)
Comment thread
imchintoo marked this conversation as resolved.
Dismissed
Comment on lines +397 to +453
Comment on lines +397 to +453
Comment on lines +397 to +453

/**
* Test-tooling facts: no framework, hand-rolled runner, no HTTP API.
* @param {string} rootDir
Expand Down Expand Up @@ -393,6 +488,7 @@
structure: getProjectStructure(dir),
workspacePackages: getWorkspacePackages(dir),
frameworkSignals: getFrameworkSignals(dir),
architectureSignals: getArchitectureSignals(dir),
test: getTestInfo(dir),
};
}
Expand All @@ -404,6 +500,7 @@
getProjectStructure,
getWorkspacePackages,
getFrameworkSignals,
getArchitectureSignals,
getTestInfo,
getAllFacts,
};
119 changes: 119 additions & 0 deletions test/project-facts.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const {
getProjectStructure,
getWorkspacePackages,
getFrameworkSignals,
getArchitectureSignals,
getTestInfo,
getAllFacts,
} = require("../lib/project-facts.js");
Expand Down Expand Up @@ -268,6 +269,123 @@ module.exports = function runProjectFactsTests(check) {
]);
});

check("getArchitectureSignals: detects 'CLI tool' from a non-empty package.json bin field", () => {
const dir = tmpRepo();
fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ bin: { "my-cli": "./bin/cli.js" } }));
const signals = getArchitectureSignals(dir);
assert.strictEqual(signals.length, 1);
assert.strictEqual(signals[0].name, "CLI tool");
assert.match(signals[0].evidence, /my-cli/);
});

check("getArchitectureSignals: detects 'Publishable library' from main/exports, preferring exports in the evidence text", () => {
const dir = tmpRepo();
fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ main: "index.js", exports: { ".": "./index.js" } }));
const signals = getArchitectureSignals(dir);
assert.strictEqual(signals.length, 1);
assert.strictEqual(signals[0].name, "Publishable library");
assert.match(signals[0].evidence, /"exports"/);
});

check("getArchitectureSignals: detects 'Monorepo (npm workspaces)' and names the resolved packages in evidence", () => {
const dir = tmpRepo();
fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ workspaces: ["packages/*"] }));
fs.mkdirSync(path.join(dir, "packages", "pkg-a"), { recursive: true });
fs.writeFileSync(path.join(dir, "packages", "pkg-a", "package.json"), JSON.stringify({ name: "pkg-a" }));
const signals = getArchitectureSignals(dir);
assert.strictEqual(signals.length, 1);
assert.strictEqual(signals[0].name, "Monorepo (npm workspaces)");
assert.match(signals[0].evidence, /pkg-a/);
});

check("getArchitectureSignals: detects 'Backend/API service' only from a dependency-confidence framework signal", () => {
const dir = tmpRepo();
fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ dependencies: { express: "^4.0.0" } }));
const signals = getArchitectureSignals(dir);
assert.strictEqual(signals.length, 1);
assert.strictEqual(signals[0].name, "Backend/API service");
});

check("getArchitectureSignals: 'Backend/API service' does NOT fire from a file-heuristic-only framework signal", () => {
const dir = tmpRepo();
fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({}));
fs.mkdirSync(path.join(dir, "src"));
fs.writeFileSync(path.join(dir, "src", "App.jsx"), ""); // file-heuristic React only, no dependency
const signals = getArchitectureSignals(dir);
assert.ok(!signals.some((s) => s.name === "Frontend application"), "file-heuristic-only signals must not promote to an architecture claim");
});

check("getArchitectureSignals: detects 'MVC-influenced layout' when at least 2 of controllers/models/views exist", () => {
const dir = tmpRepo();
fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({}));
fs.mkdirSync(path.join(dir, "src", "controllers"), { recursive: true });
fs.mkdirSync(path.join(dir, "src", "models"), { recursive: true });
const signals = getArchitectureSignals(dir);
assert.strictEqual(signals.length, 1);
assert.strictEqual(signals[0].name, "MVC-influenced layout");
assert.match(signals[0].evidence, /controllers/);
assert.match(signals[0].evidence, /models/);
});

check("getArchitectureSignals: does NOT fire MVC on just 1 matching directory name", () => {
const dir = tmpRepo();
fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({}));
fs.mkdirSync(path.join(dir, "src", "controllers"), { recursive: true });
assert.deepStrictEqual(getArchitectureSignals(dir), []);
});

check("getArchitectureSignals: detects 'Layered/service-oriented layout' from either directory-name group, reported as one signal even if both groups match", () => {
const dir = tmpRepo();
fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({}));
fs.mkdirSync(path.join(dir, "src", "routes"), { recursive: true });
fs.mkdirSync(path.join(dir, "src", "services"), { recursive: true });
fs.mkdirSync(path.join(dir, "src", "domain"), { recursive: true });
fs.mkdirSync(path.join(dir, "src", "application"), { recursive: true });
const signals = getArchitectureSignals(dir);
assert.strictEqual(signals.length, 1, "both matching groups should still produce exactly one 'Layered/service-oriented layout' signal, not two");
assert.strictEqual(signals[0].name, "Layered/service-oriented layout");
assert.match(signals[0].evidence, /routes/);
assert.match(signals[0].evidence, /domain/);
});

check("getArchitectureSignals: multiple independent signals co-occur on one fixture (never collapsed into a single label)", () => {
const dir = tmpRepo();
fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ bin: { cli: "./bin/cli.js" }, main: "index.js", dependencies: { "@nestjs/core": "^10.0.0" } }));
fs.mkdirSync(path.join(dir, "src", "controllers"), { recursive: true });
fs.mkdirSync(path.join(dir, "src", "models"), { recursive: true });
const signals = getArchitectureSignals(dir);
const names = signals.map((s) => s.name).sort();
assert.deepStrictEqual(names, ["Backend/API service", "CLI tool", "MVC-influenced layout", "Publishable library"]);
});

check("getArchitectureSignals: returns an empty array on a plain repo with none of the 7 signals", () => {
const dir = tmpRepo();
fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ dependencies: { lodash: "^4.0.0" } }));
fs.mkdirSync(path.join(dir, "lib"));
assert.deepStrictEqual(getArchitectureSignals(dir), []);
});

check("getArchitectureSignals: this repo's own real signals report CLI tool + Publishable library + Monorepo + Layered/service-oriented layout", () => {
// Verified against this repo's actual real directory tree (`find .
// -mindepth 1 -maxdepth 3 -type d`) before writing this assertion,
// not assumed: root package.json has both bin.gen-comments/
// bin.gen-docs and exports, one real npm workspace
// (eslint-plugin-jsdoc-scribe), and sample/express/ genuinely has
// both routes/ and services/ directories (fixture data, but the
// detector has no way to know that -- and per the ADR it
// shouldn't guess intent, only report what it found). No MVC
// signal (only controllers/ exists, not 2 of the 3 names), no
// Backend/API service or Frontend application (no framework
// dependency at confidence: "dependency" anywhere in this repo).
const signals = getArchitectureSignals(path.resolve(__dirname, ".."));
assert.deepStrictEqual(signals, [
{ name: "CLI tool", evidence: "package.json \"bin\": gen-comments, gen-docs" },
{ name: "Publishable library", evidence: "package.json has a \"exports\" field" },
{ name: "Monorepo (npm workspaces)", evidence: "1 workspace package(s): eslint-plugin-jsdoc-scribe" },
{ name: "Layered/service-oriented layout", evidence: "directories present: routes, services" },
]);
});

check("getTestInfo: reports no framework and no HTTP API", () => {
const facts = getTestInfo(tmpRepo());
assert.strictEqual(facts.framework, null);
Expand All @@ -291,6 +409,7 @@ module.exports = function runProjectFactsTests(check) {
assert.ok("structure" in facts);
assert.ok("workspacePackages" in facts);
assert.ok("frameworkSignals" in facts);
assert.ok("architectureSignals" in facts);
assert.ok("test" in facts);
});
};
Loading