Skip to content

feat(project-facts): architecture-pattern signals - #7

Merged
imchintoo merged 1 commit into
mainfrom
feature/architecture-pattern-signals
Jul 15, 2026
Merged

feat(project-facts): architecture-pattern signals#7
imchintoo merged 1 commit into
mainfrom
feature/architecture-pattern-signals

Conversation

@imchintoo

Copy link
Copy Markdown
Owner

What does this PR do?

Adds getArchitectureSignals() to lib/project-facts.js: 7 deterministic,
evidence-backed architecture-pattern signals (CLI tool, Publishable library, Monorepo (npm workspaces), Backend/API service, Frontend application, MVC-influenced layout, Layered/service-oriented layout).
Every signal is { name, evidence } — multiple signals commonly co-occur
by design; there is deliberately no single "the architecture is X" verdict
(this repo itself is CLI tool + library + monorepo simultaneously — a
forced single label would be wrong for the exact repo this ships in).

Backend/API service/Frontend application only key off Phase 2 signals
at confidence: "dependency" — a file-heuristic-only framework guess
never promotes into an architecture claim. Zero new filesystem calls —
every rule reuses Phase 1 (getProjectStructure, getWorkspacePackages)
and Phase 2 (getFrameworkSignals) output.

This is the highest-risk phase per the epic (false-positive claims
undercut the "no AI, no surprises" trust moat), so it was gated behind
adr-architecture-pattern-signals.md before any code was written — see
that ADR's Alternatives-considered section for why a single-label or
scored-confidence design was rejected.

Implements Phase 3 (task-arch-03) of epic-architecture-insight-report,
on top of Phase 1 (#5) and Phase 2 (merged).

Related issue

Ref: docs/backlog/epic-architecture-insight-report.md,
docs/backlog/story-architecture-pattern-signals.md,
docs/backlog/adr-architecture-pattern-signals.md,
docs/backlog/task-arch-03-pattern-signals.md

Checklist

  • npm test passes locally — full suite: 283 tests passed, 0 failures
    (includes 11 new getArchitectureSignals tests; project-facts
    suite total 38/38)
  • Added/updated tests for the behavior change — 11 new tests,
    including one per rule firing independently, one for multi-signal
    co-occurrence, one proving file-heuristic signals don't promote to
    architecture claims, and an exact-signal assertion against this
    repo's own real facts (verified against find . -mindepth 1 -maxdepth 3 -type d output before writing the assertion, not
    assumed — sample/express/routes|services genuinely triggers the
    layered-layout signal on this repo)
  • No new runtime dependency added
  • Output remains deterministic (same input → same output, no
    timestamps/randomness — pure fs/JSON.parse reads, zero new
    filesystem traversal)
  • Updated CHANGELOG.md — not done, same reasoning as Phase 1/2: no
    consumer wires this into CLI/generated output yet. Worth revisiting
    once all 3 phases are ready to surface in gen-docs output.

Comment thread lib/project-facts.js Dismissed
Comment thread lib/project-facts.js
Comment on lines +397 to +453
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;
}
Comment thread lib/project-facts.js
Comment on lines +397 to +453
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;
}
Comment thread lib/project-facts.js
Comment on lines +397 to +453
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;
}
@imchintoo
imchintoo merged commit 509a816 into main Jul 15, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants