diff --git a/skills/feature-arch/SKILL.md b/skills/feature-arch/SKILL.md new file mode 100644 index 00000000..f92b0cb6 --- /dev/null +++ b/skills/feature-arch/SKILL.md @@ -0,0 +1,120 @@ +--- +name: feature-arch +description: > + A skill for feature-based development using the js-plugin package in Node.js or frontend + applications. Use this whenever the user is working with features under a src/features/ + directory — creating features, wiring them via extension points, updating specs, or + managing feature boundaries. +--- + +# Feature-Based Development Skill + +This skill describes a feature-based development approach powered by the [js-plugin](./references/js-plugin.md) package. It applies to both Node.js backend and frontend UI applications. + +**Before proceeding with any feature work, read `./references/js-plugin.md`** to understand the plugin API and extension point patterns. The entire approach depends on it. + +--- + +## Folder Structure + +Each feature lives in its own subdirectory under `src/features/`: + +``` +src/features/ +├── feature1/ +│ ├── ext/ +│ │ └── index.js # Extension point contributions from this feature +│ ├── index.js # Feature plugin definition +│ └── FEATURE_SPEC.md # Feature specification +├── feature2/ +│ ├── ext/ +│ │ └── index.js +│ ├── index.js +│ └── FEATURE_SPEC.md +└── ... +``` + +Tip: you can use `.ts[x]` , `.js[x]` for index files, not limited to `.js`. + +--- + +## What Is a Feature? + +A feature is a cohesive group of related capabilities. All features work together to form the complete application, but each one is independently removable. + +**Core rules:** + +- Every feature lives as a subdirectory under `src/features/`. +- Every feature is implemented as a js-plugin plugin. +- A feature must be removable without breaking the rest of the application — its absence degrades functionality gracefully rather than causing crashes or errors. +- Features communicate with each other exclusively through **extension points** and **exports** — never through direct imports across feature boundaries. +- Add extension points or exports to a feature only when integration with another feature actually requires it. + +--- + +## FEATURE_SPEC.md + +Every feature folder must contain a `FEATURE_SPEC.md` file. This file defines the feature's boundary and serves as the source of truth for its capabilities. + +**Purpose:** The spec establishes what the feature does and how it does it — not why it exists. Keep it precise and current. + +**When to update:** + +- Add an entry whenever a new capability is added to the feature. +- When the user manually edits the spec, treat it as an implementation directive and apply the changes in code. + +**Boundary enforcement:** If a proposed change seems to exceed the feature's defined scope, pause and ask the user whether a new feature is needed rather than expanding the current one silently. + +**Suggested sections** — include only what's relevant, and add any other sections that help describe the feature clearly: + +| Section | Content | +| ---------------------------- | ----------------------------------------------------------------- | +| **Overview** | A short, accurate description of what the feature does | +| **UI Requirements** | Layout, components, interactions, and visual behavior | +| **Performance Requirements** | Loading targets, caching strategy, optimization constraints | +| **Security Requirements** | Auth checks, data access rules, input validation | +| **Extensibility** | Extension points exposed, global shared modal IDs, plugin exports | + +Keep the spec concise. Avoid rationale or background — focus on _what_ and _how_. + +--- + +## Workflow + +### Creating a new feature + +When the user asks to create a new feature: + +1. Create a subdirectory under `src/features/` with a name that reflects the feature's purpose. +2. Set up the standard folder structure (`ext/index.js`, `index.js`, `FEATURE_SPEC.md`). +3. Write an initial `FEATURE_SPEC.md` with an accurate overview and placeholder sections for UI, performance, security, and extensibility. +4. Scaffold the js-plugin plugin in `index.js`. + +### Implementing or modifying a feature + +When adding or changing capabilities: + +1. Review the feature's `FEATURE_SPEC.md` to confirm the change is in scope. +2. If the change is in scope, implement it and update the spec to reflect the new capability. +3. If the change appears out of scope, ask the user whether to expand the spec or create a new feature. +4. After applying any code changes, verify that the implementation matches the spec. + +### Wiring features together + +Use extension points for inter-feature communication: + +- A feature that needs to expose behavior for others defines an extension point in its plugin. +- A feature that contributes to another's extension point does so in its `ext/index.js`. +- Never import directly from another feature's internal modules — always go through the plugin's public interface. + +--- + +## Quick Reference + +| Question | Answer | +| ---------------------------------------------- | ------------------------------------------------------------- | +| Where do new features go? | `src/features//` | +| How do features share behavior? | Extension points and plugin exports | +| What happens if I remove a feature? | App still runs; that feature's capabilities are simply absent | +| Where is a feature's contract defined? | `FEATURE_SPEC.md` | +| When do I create a new feature vs. extend one? | When the capability is outside the existing spec's boundary | diff --git a/skills/feature-arch/references/js-plugin.md b/skills/feature-arch/references/js-plugin.md new file mode 100644 index 00000000..35c64a9e --- /dev/null +++ b/skills/feature-arch/references/js-plugin.md @@ -0,0 +1,270 @@ +# js-plugin Reference + +`js-plugin` is a lightweight, general-purpose plugin engine for building extensible JavaScript applications. It provides the **extension point** pattern that lets independent features discover and integrate with each other without direct coupling. + +Works in both browser and Node.js environments. If you need to understand internals (caching, registration ordering, invocation mechanics), read the source directly — it's ~150 lines. + +--- + +## The Problem It Solves + +Without a plugin system, adding features requires modifying shared files that every feature touches: + +```javascript +// menu.js — WITHOUT js-plugin +function Menu() { + return ( + + ); +} +``` + +**With js-plugin**, each feature registers its own contributions, and shared components collect them dynamically: + +```javascript +// menu.js — WITH js-plugin +import plugin from 'js-plugin'; + +function Menu() { + const items = plugin.invoke('menu.getItems'); + return ; +} + +// blocked-users/index.js +plugin.register({ + name: 'blocked-users', + menu: { getItems: () => ({ key: 'blocked', label: 'Blocked Users' }) } +}); + +// api-keys/index.js +plugin.register({ + name: 'api-keys', + menu: { getItems: () => ({ key: 'api-keys', label: 'API Keys' }) } +}); +``` + +`menu.js` never changes when features are added or removed. Each feature's code stays self-contained. + +--- + +## API Reference + +### `plugin.register(pluginObject)` + +Register a feature plugin. Call this at module top-level in the feature's entry file, before the app renders. + +```javascript +import plugin from 'js-plugin'; + +plugin.register({ + name: 'notifications', // Required: unique identifier + deps: ['auth'], // Optional: plugins that must be registered first + initialize() { // Optional: called immediately after registration + console.log('notifications ready'); + }, + // Everything else is an extension point contribution: + menu: { + getItems: () => [{ key: 'notif', label: 'Notifications', order: 20 }] + }, + route: { path: '/notifications', component: NotificationsPage } +}); +``` + +- If a declared `deps` entry is missing from the registry, the plugin is excluded from all invocations and a console warning is logged. +- Never register conditionally — plugins should be statically registered. + +--- + +### `plugin.invoke(extPoint, ...args)` + +Collect contributions from all plugins that implement an extension point. Returns an array — one entry per contributing plugin. + +| Syntax | Behavior | +|---|---| +| `plugin.invoke('a.b')` | Calls `a.b` as a function if it is one; otherwise returns the value | +| `plugin.invoke('!a.b')` | Always returns the value without calling, even if it's a function | +| `plugin.invoke('a.b!')` | Same as default, but throws errors instead of swallowing them | + +```javascript +// Call a lifecycle hook on every feature +plugin.invoke('onInit'); + +// Collect plain values +const routes = plugin.invoke('!route'); +// → [{ path: '/a', component: A }, { path: '/b', component: B }] + +// Call with arguments +const items = plugin.invoke('menu.getItems', currentUser); +// → calls menu.getItems(currentUser) on each plugin that defines it + +// Collect functions to call later +const getters = plugin.invoke('!getHeaderWidget'); +// → [fn1, fn2] — call when ready: getters.map(fn => fn()) +``` + +--- + +### `plugin.getPlugin(name)` + +Look up a specific plugin by name. Returns the plugin object or `undefined`. + +```javascript +const auth = plugin.getPlugin('auth'); +if (auth) { + const user = auth.exports.getCurrentUser(); +} +``` + +**Never call at module top-level** — the registry populates as modules load, so other plugins may not be registered yet: + +```javascript +// ❌ Too early +const auth = plugin.getPlugin('auth'); + +// ✅ Inside a function, called after all plugins are loaded +function handleLogin() { + const auth = plugin.getPlugin('auth'); + auth?.exports.login(); +} +``` + +--- + +### `plugin.getPlugins(extPoint?)` + +Get all plugins contributing to an extension point. Omit the argument to get all registered plugins. + +```javascript +const all = plugin.getPlugins(); +const routed = plugin.getPlugins('route'); +const contributors = plugin.getPlugins('menu.getItems'); +``` + +Plugins with unresolved dependencies are automatically excluded. + +--- + +### `plugin.sort(array, sortProp?)` + +Sort an array of objects by a numeric property (default: `'order'`), in-place. Objects missing the property go to the end. + +```javascript +const items = plugin.invoke('menu.getItems').flat(); +plugin.sort(items); // sorts by item.order +``` + +--- + +### `plugin.unregister(name)` + +Remove a plugin from the registry. Mainly useful in tests and hot-reload scenarios. + +--- + +### `plugin.config` + +```javascript +plugin.config.throws = true; // Make all invocations throw on error +``` + +--- + +## Exports Pattern + +`exports` is a js-plugin convention for sharing APIs, utilities, components, or services between features. Define an `exports` property on your plugin registration — other features access it via `plugin.getPlugin()`. + +```javascript +// feature-a/index.js +import plugin from 'js-plugin'; +import * as hooks from './hooks'; +import * as utils from './utils'; +import apiClient from './apiClient'; + +plugin.register({ + name: 'feature-a', + exports: { + hooks, // e.g. useFeatureAData, useFeatureAAuth + utils, // e.g. formatItem, parseConfig + apiClient // configured axios instance or similar + } +}); +``` + +```javascript +// feature-b/SomeComponent.jsx +import plugin from 'js-plugin'; + +function SomeComponent() { + const { hooks, utils } = plugin.getPlugin('feature-a')?.exports || {}; + const data = hooks?.useFeatureAData(); + // ... +} +``` + +**When to use exports vs extension points:** + +- Use **exports** when one feature needs to *consume* APIs or code owned by another — hooks, utilities, configured service clients, shared components. +- Use **extension points** when a feature wants to let others *contribute* capabilities to it. Exports create direct coupling; extension points stay loose. + +**Avoid calling `plugin.getPlugin()` at module top level** — the registry may not be fully populated yet when the module first evaluates. Always call it inside a function, component, or hook. + +```javascript +// ❌ Too early — feature-a may not be registered yet +const { hooks } = plugin.getPlugin('feature-a').exports; + +// ✅ Inside a function — safe +function MyComponent() { + const { hooks } = plugin.getPlugin('feature-a')?.exports || {}; +} +``` + +Document your feature's exports in its `FEATURE_SPEC.md` so other features know what's available and how to access it. + +--- + +## Extension Point Conventions + +### Use nested objects, not string keys + +Extension points are resolved by traversing object properties, not by parsing dot-notation strings: + +```javascript +// ✅ Correct +plugin.register({ + name: 'my-feature', + layout: { sidebar: { getItems: () => [...] } } +}); + +// ❌ Wrong — string path keys are not traversed +plugin.register({ + name: 'my-feature', + 'layout.sidebar.getItems': () => [...] +}); +``` + +### Extension points are implicitly defined + +There is no central registry of extension points. A point exists when a consumer calls `plugin.invoke('some.point')` and contributors register a matching property path. Document your feature's extension points in `FEATURE_SPEC.md` so other features know how to contribute. + +### Two roles for every extension point + +```javascript +// Provider — defines the extension point by consuming it +function Sidebar() { + const items = plugin.invoke('sidebar.getItems').flat(); + plugin.sort(items); + return ; +} + +// Contributor — any feature that wants to appear in the sidebar +plugin.register({ + name: 'reports', + sidebar: { getItems: () => [{ key: 'reports', label: 'Reports', order: 40 }] } +}); +```