chore: fork @llblab/[email protected].0 as @evandrodevbr/oh-my-pi-telegram
Fork of upstream llblab/[email protected].0 (SHA afe09c5) repackaged as a drop-in extension for can1357/oh-my-pi (omp >= 16.5.2). Changes vs upstream: - Rename package: @llblab/pi-telegram -> @evandrodevbr/oh-my-pi-telegram - Bump version: 0.22.0 -> 0.22.0-evandro.1 - Update repository/homepage/bugs URLs to evandrodevbr - Add omp field in package.json (displayName + compatibleWith) - README: prepend 'Oh My Pi (omp) Compatibility' section with install commands, verified-components table, upstream lineage, and rationale - CHANGELOG: add '0.22.0-evandro.1' entry under Unreleased - docs/: rename @llblab/pi-telegram imports to @evandrodevbr/oh-my-pi-telegram - tests/public-api.test.ts: rename package import paths Gates re-run on the fork: - npm run typecheck -> tsc --noEmit (success) - npm test -> 1229 pass / 0 fail / 1 platform-only skip - npm run audit -> found 0 vulnerabilities - npm run pack:check -> evandrodevbr-oh-my-pi-telegram-0.22.0-evandro.1.tgz Verified: - omp install --link ./pi-telegram -> Linked OK - omp plugin list -> @evandrodevbr/[email protected].0-evandro.1 Upstream lineage: badlogic/pi-telegram -> llblab/pi-telegram (0.22.0) -> evandrodevbr/oh-my-pi-telegram (this fork)
This commit is contained in:
commit
d1d04001d0
155 files changed
+107848
No files matched your search
@@ -0,0 +1,50 @@
|
||||
# AGENTS.md (domain-dag)
|
||||
|
||||
## Knowledge & Conventions
|
||||
|
||||
### Meta-Protocol Principles
|
||||
|
||||
- 'Domain Ownership': Every durable responsibility should have a single owning source module or package.
|
||||
- 'Acyclic Direction': Local source imports should form a directed acyclic graph.
|
||||
- 'Composition Boundary': Entrypoints compose ports and runtimes; domain modules do not import entrypoints.
|
||||
- 'Transportability': The skill and validator must avoid project-specific names, absolute local paths, stack-only assumptions, and external dependencies.
|
||||
- 'Explicit Non-Ownership': Durable module contracts should state what they own and what they reject.
|
||||
- 'Interface Surface Pressure': Wide callback/parameter/export surfaces are architectural pressure to group related concepts into named contracts, not automatic failures.
|
||||
- 'Shape Before Rules': First identify whether the project is flat, layered, package-based, hexagonal, or hybrid; then choose validation rules.
|
||||
|
||||
### Operating Principles
|
||||
|
||||
- Use `scripts/validate-domain-dag.sh` for graph audits.
|
||||
- Prefer config-driven project rules over hard-coded repository assumptions.
|
||||
- Keep validator defaults portable; make stack-specific checks opt-in via config.
|
||||
- Use import aliases from config when a project routes local imports through bundler/tsconfig/package aliases.
|
||||
- Treat cycles and reverse entrypoint imports as hard failures.
|
||||
- Treat generic shared-bucket names as warnings unless a project config upgrades them.
|
||||
- Add custom alias, layer, forbidden-edge, header-clause, and surface-width rules only for discovered project constraints.
|
||||
|
||||
### Discovered Constraints
|
||||
|
||||
- Shared-bucket names are heuristic, not proof of bad architecture.
|
||||
- Trigger: `utils`, `types`, `shared`, or similar names appear.
|
||||
- Action: Audit ownership and either move content to an owner or allow the exception.
|
||||
- Header labels are a navigation aid, not the graph itself.
|
||||
- Trigger: A project lacks module headers but has clean boundaries.
|
||||
- Action: Warn by default; use strict/config only when headers are adopted standard.
|
||||
- Layer checks must be opt-in.
|
||||
- Trigger: Generic validation across unknown projects.
|
||||
- Action: Validate universal graph invariants by default; enforce local direction only from config.
|
||||
- Header clauses are earned standards.
|
||||
- Trigger: A project adopts explicit ownership headers such as owner/exclusion clauses.
|
||||
- Action: Configure `headerRequiredClauses`; do not bake local wording into the skill default.
|
||||
- Wide interfaces are pressure, not proof.
|
||||
- Trigger: A child boundary accumulates many callbacks, methods, exports, or parameters.
|
||||
- Action: Prefer grouped contracts/ports when stable; use `surfaceRules` as warnings unless noise-free.
|
||||
- Decomposition has a stop rule.
|
||||
- Trigger: Further extraction would mostly pass host state through or hide control flow.
|
||||
- Action: Stop, review, and document the boundary rather than creating one-use wrappers.
|
||||
- Flat and layered DAGs are both first-class.
|
||||
- Trigger: An extension/CLI has one entrypoint plus cohesive flat domain files, or a frontend/service has earned layered route/use-case/domain separation.
|
||||
- Action: Preserve the local shape if dependency direction and ownership are clear; do not impose the other shape for aesthetic consistency.
|
||||
- Severity is a ladder.
|
||||
- Trigger: A heuristic finds pressure but not a concrete break.
|
||||
- Action: Keep it as a warning until repeated evidence proves it should fail builds.
|
||||
@@ -0,0 +1,273 @@
|
||||
---
|
||||
name: domain-dag
|
||||
description: Validates and guides Domain DAG architecture for domain ownership, acyclic local dependency graphs, composition roots, boundary direction, public contracts, interface-surface pressure, and shared-bucket drift. Use when auditing, refactoring, or extending modular codebases across frontend, backend, extensions, CLIs, SDKs, and service packages.
|
||||
metadata:
|
||||
version: 1.0.16
|
||||
---
|
||||
|
||||
# Domain DAG
|
||||
|
||||
## Purpose
|
||||
|
||||
Maintain source code as a directed acyclic graph of cohesive responsibility modules.
|
||||
|
||||
A Domain DAG keeps architecture readable by making every durable module answer four questions:
|
||||
|
||||
1. **What does this module own?**
|
||||
2. **What does it explicitly not own?**
|
||||
3. **Which lower or peer contracts may it depend on?**
|
||||
4. **Where is live composition allowed to happen?**
|
||||
|
||||
The skill is not tied to one stack or folder style. It applies to frontend features, backend bounded contexts, extension hosts, SDK packages, CLIs, workers, and service adapters.
|
||||
|
||||
## Core Model
|
||||
|
||||
```text
|
||||
composition root / delivery surface
|
||||
→ orchestration or use-case modules
|
||||
→ domain capability modules
|
||||
→ support/foundation modules
|
||||
→ platform, runtime, standard library, external adapters
|
||||
```
|
||||
|
||||
The filesystem may be flat, layered, package-based, or mixed. The graph is architectural, not decorative: ownership lives in module boundaries, import direction, public contracts, and explicit composition points.
|
||||
|
||||
## Common Shapes
|
||||
|
||||
Domain DAG is a graph discipline, not one folder template.
|
||||
|
||||
- **Flat domain DAG**: one entrypoint plus flat domain files/packages. Best for extensions, CLIs, small services, and runtimes where deep folders would hide rather than clarify ownership.
|
||||
- **Layered domain DAG**: routes/controllers/features/use-cases compose reusable domain and support modules. Best when delivery surfaces are numerous and entity capabilities are reused across screens or endpoints.
|
||||
- **Package DAG**: apps depend on packages, packages depend on foundation or peer public contracts. Best for monorepos and SDKs.
|
||||
- **Hexagonal/ports DAG**: use cases depend on ports/contracts while adapters live at composition boundaries. Best for backends, workers, queues, and external integrations.
|
||||
- **Hybrid DAG**: different bounded contexts use different local shapes, but the global dependency direction stays acyclic and explicit.
|
||||
|
||||
Choose the smallest shape that explains the code. Do not force a layered tree onto a flat extension or flatten a product frontend that has earned route/feature/domain separation.
|
||||
|
||||
## Axioms
|
||||
|
||||
### A1: Domain Ownership
|
||||
|
||||
Each durable responsibility has one owning module or package. Types, constants, helpers, adapters, state, policies, and tests that define a responsibility live with that owner.
|
||||
|
||||
### A2: Explicit Non-Ownership
|
||||
|
||||
A strong boundary says what it does **not** own. This prevents accumulation of adjacent workflows, transport concerns, persistence policy, UI chrome, or runtime wiring just because the code is nearby.
|
||||
|
||||
### A3: Directed Imports
|
||||
|
||||
Local imports must form a DAG. Cycles are architecture bugs because they hide ownership and make change impact non-local.
|
||||
|
||||
### A4: Composition Root Boundary
|
||||
|
||||
Entrypoints wire live ports, configuration, adapters, routes, framework lifecycles, queues, and domain runtimes. Domain modules must not import composition roots.
|
||||
|
||||
### A5: Shared Bucket Resistance
|
||||
|
||||
Files or folders named `types`, `constants`, `utils`, `helpers`, `shared`, or `common` are suspect. Use them only when the responsibility is genuinely cross-domain and cannot belong to a more specific owner.
|
||||
|
||||
### A6: Public Contracts Over Reach-Through
|
||||
|
||||
When a module needs another module's behavior, depend on the smallest public contract that expresses the capability. Avoid importing internals, broad mutable runtime state, or sibling orchestration surfaces.
|
||||
|
||||
### A7: Progressive Disclosure
|
||||
|
||||
Start with the smallest useful graph. Add layers, public barrels, forbidden edges, header clauses, and custom checks only after real project constraints make them valuable.
|
||||
|
||||
### A8: Interface Surface Pressure
|
||||
|
||||
A very wide boundary can be as harmful as a wrong dependency. Many callbacks, parameters, exported knobs, or adapter methods are pressure to group related concepts into named contracts, ports, state objects, command objects, or smaller capabilities.
|
||||
|
||||
## Placement Heuristics
|
||||
|
||||
Use these questions when creating or moving code:
|
||||
|
||||
- **Survival test**: If the current route/screen/job/command disappeared, would this module still be useful? If yes, it likely belongs below the orchestration layer.
|
||||
- **Noun vs journey**: Business nouns/capabilities belong in domain modules. User journeys, workflows, route handlers, modal/screen flows, jobs, and shell composition belong in orchestration/features/use cases.
|
||||
- **Reuse pressure**: Peer orchestration modules should not import each other for shared behavior. Extract the reusable capability down to a domain/support module, or extract generic mechanics down to foundation/UI/platform.
|
||||
- **Entrypoint shim pressure**: If executable scripts grow substantive behavior, keep the executable file as a thin runner and move the behavior into a named compiled domain module when reuse, testing, packaging, or runtime compatibility benefits are plausible. Do not extract self-contained app scripts whose logic has no expected second consumer; keep them standalone when the boundary would be theater.
|
||||
- **Policy locality**: Persistence policy, transaction boundaries, routing, lifecycle registration, drag/drop registration, message acknowledgement, and external transport wiring usually remain in the owning composition/use-case layer unless they are themselves reusable policies.
|
||||
- **Naming is not ownership**: A `cards` feature and a `cards` domain can coexist if one owns the journey and the other owns reusable card capabilities.
|
||||
- **Public surface test**: Consumers should import what the owner deliberately exposes, not whatever file is easy to reach. Public barrels, package exports, ports, facades, or documented function groups are all valid contracts when they fit the ecosystem.
|
||||
- **Flat-shape caution**: In a flat DAG, file count is not the main health metric. Cohesion, acyclicity, explicit headers, and absence of shared buckets matter more.
|
||||
|
||||
## Calibration Protocol
|
||||
|
||||
Before adding or enforcing rules, calibrate the project:
|
||||
|
||||
1. Identify the shape: flat, layered, package, hexagonal, or hybrid.
|
||||
2. Find the true composition roots: app bootstrap, extension entrypoint, HTTP router, worker runner, CLI command, SDK facade, test harness.
|
||||
3. Identify the project’s public-contract mechanism: barrels, package exports, interfaces, ports, route handlers, generated clients, or documented namespaces.
|
||||
4. Run generic validation first; treat warnings as leads, not facts.
|
||||
5. Add project-local config only for constraints the project has actually earned.
|
||||
6. Keep hard errors for low-noise invariants; keep heuristics as warnings.
|
||||
|
||||
## Extraction Protocol
|
||||
|
||||
When decomposing or extending a module:
|
||||
|
||||
1. Identify the host's real responsibility and write it down.
|
||||
2. Find stable seams: repeated entity surfaces, reusable policy, narrow UI/control blocks, adapters, or pure transformations.
|
||||
3. Extract only if the child has a clear owner and exclusion list.
|
||||
4. Keep persistence, runtime lifecycle, routing, external effects, and screen/job orchestration in the host unless the extracted module explicitly owns that policy.
|
||||
5. Replace cross-feature or cross-use-case reuse with lower-layer contracts rather than peer imports.
|
||||
6. Run the validator and the project's normal checks.
|
||||
7. Stop when the host mainly coordinates state/effects and the next extraction would hide control flow or create a one-use wrapper.
|
||||
|
||||
## Stop Rules
|
||||
|
||||
Do **not** keep decomposing just because a file is large. Stop when:
|
||||
|
||||
- Remaining code is orchestration glue with high local context value.
|
||||
- The module is a self-contained executable application and its behavior is unlikely to be imported by another domain.
|
||||
- The next slice would have no reusable responsibility beyond “some markup from the host”.
|
||||
- The extracted child would need most of the host state as props.
|
||||
- The boundary would create a long flat callback list instead of a meaningful contract.
|
||||
- Behavior risk exceeds architectural gain.
|
||||
|
||||
At this point, switch to review: check for misplaced ownership, too-wide contracts, stale imports, and validator gaps.
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
- **Folder theater**: creating layers or domain folders without changing dependency direction or ownership clarity.
|
||||
- **Peer feature reach-through**: one workflow imports another workflow because it wants a reusable part.
|
||||
- **Shared bucket gravity**: generic `types`, `utils`, or `constants` become the real hidden domain.
|
||||
- **God facade**: a public contract exposes most internals and gives consumers no smaller capability boundary.
|
||||
- **RPC child component/module**: a child boundary accepts dozens of callbacks or flags instead of a named contract.
|
||||
- **Premature atomization**: many one-use files obscure the main control flow and make debugging harder.
|
||||
- **Validator absolutism**: heuristic warnings are promoted to errors before the signal is proven stable.
|
||||
|
||||
## Validation
|
||||
|
||||
Run the bundled validator from a project root:
|
||||
|
||||
```bash
|
||||
SKILL_DIR=/path/to/domain-dag
|
||||
bash "${SKILL_DIR}/scripts/validate-domain-dag.sh" --root .
|
||||
```
|
||||
|
||||
Useful flags:
|
||||
|
||||
- `--root <path>` — project root; defaults to the current directory
|
||||
- `--config <path>` — JSON config; defaults to `domain-dag.json`, then `.domain-dag.json`
|
||||
- `--strict` — treat warnings as failures
|
||||
- `--json` — machine-readable output
|
||||
|
||||
The validator checks:
|
||||
|
||||
- Local source import graph has no cycles, including configured local import aliases
|
||||
- Domain modules do not import configured entrypoints
|
||||
- Optional domain headers are present
|
||||
- Optional required header clauses are present
|
||||
- Shared-bucket filenames and folders are reported
|
||||
- Optional flat-root, layer-order, forbidden-edge, and surface-width rules hold
|
||||
|
||||
## Configuration
|
||||
|
||||
Add a project-local `domain-dag.json` when defaults are too broad or too narrow:
|
||||
|
||||
```json
|
||||
{
|
||||
"sourceRoots": ["src", "lib"],
|
||||
"sourceExtensions": [".ts", ".tsx", ".js", ".jsx", ".svelte"],
|
||||
"entrypoints": ["src/main.ts", "src/app/bootstrap.ts"],
|
||||
"importAliases": {
|
||||
"@/*": "src/*",
|
||||
"$lib/*": "src/lib/*"
|
||||
},
|
||||
"requireHeaders": true,
|
||||
"headerPattern": "\\b(Domain|Domains|Zone|Zones|Owns):\\s*\\S",
|
||||
"headerSeverity": "warn",
|
||||
"headerRequiredClauses": ["Owns:", "Excludes:"],
|
||||
"headerRequiredClausesSeverity": "warn",
|
||||
"flatRoots": false,
|
||||
"sharedBucketSeverity": "warn",
|
||||
"allowedSharedBuckets": ["src/platform/shared/**"],
|
||||
"surfaceRules": [
|
||||
{
|
||||
"name": "wide callback surface",
|
||||
"files": ["src/features/**"],
|
||||
"pattern": "\\b(on[A-Z][A-Za-z0-9_]*)\\b",
|
||||
"max": 15,
|
||||
"severity": "warn",
|
||||
"message": "Wide callback surface; group related handlers into contract objects"
|
||||
}
|
||||
],
|
||||
"forbiddenEdges": [
|
||||
{
|
||||
"from": "src/domain/**",
|
||||
"to": "src/app/**",
|
||||
"severity": "error",
|
||||
"message": "Domain layer must not import app layer"
|
||||
}
|
||||
],
|
||||
"layers": [
|
||||
{
|
||||
"name": "foundation",
|
||||
"rank": 0,
|
||||
"files": ["src/platform/**"]
|
||||
},
|
||||
{
|
||||
"name": "domain",
|
||||
"rank": 1,
|
||||
"files": ["src/domain/**"]
|
||||
},
|
||||
{
|
||||
"name": "composition",
|
||||
"rank": 2,
|
||||
"files": ["src/app/**", "src/main.ts"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Layer rule: lower ranks must not import higher ranks. Same-rank and downward imports are allowed.
|
||||
|
||||
`importAliases` lets the validator resolve local path aliases such as `@/*`, `$lib/*`, or package-internal aliases. Keep aliases project-local; the skill should not assume any particular bundler or language server convention.
|
||||
|
||||
Glob support is intentionally small and portable: `*`, `**`, and `?` are supported, but brace expansion such as `src/{a,b}/**` is not. Use multiple explicit glob entries or rules instead; the validator warns when configured globs contain unsupported braces.
|
||||
|
||||
`surfaceRules` are intentionally generic: by default they count unique regex matches in selected files, and with `metric: "lines"` they count file lines. Use them for local pressure signals such as too many callback props, too many exported commands, broad adapter methods, oversized widgets/modules, or other project-specific interface smells. Keep them warnings unless the signal is proven noise-free.
|
||||
|
||||
## Rule-Severity Ladder
|
||||
|
||||
Use severity deliberately:
|
||||
|
||||
- **Error**: objective graph break or proven boundary breach — cycles, reverse entrypoint import, configured forbidden edge, known-invalid layer direction.
|
||||
- **Warning**: architectural pressure — shared bucket names, missing headers, wide interfaces, large modules, broad exports, suspicious reach-through.
|
||||
- **Off / documented exception**: local shape intentionally violates a heuristic and the reason is durable.
|
||||
|
||||
A mature Domain DAG has few hard rules and good explanations. It does not need many rules to be strong.
|
||||
|
||||
## Operating Protocol
|
||||
|
||||
1. Identify composition roots, delivery surfaces, domain roots, and support/foundation roots.
|
||||
2. Classify the project shape: flat, layered, package, hexagonal, or hybrid.
|
||||
3. Build or update the ownership map: owner, public contract, explicit exclusions.
|
||||
4. Run `scripts/validate-domain-dag.sh` with generic defaults.
|
||||
5. If aliases, layers, or public boundaries are invisible to generic validation, add project-local config.
|
||||
6. Fix hard failures before cosmetic refactors.
|
||||
7. Move misplaced types/constants/helpers/policies to their owning module.
|
||||
8. Replace broad concrete reach-through with narrow ports/contracts when dependency direction is wrong.
|
||||
9. Group wide interfaces into named contracts when the surface is stable.
|
||||
10. Add custom forbidden edges, header clauses, or surface rules only after a real boundary has been violated.
|
||||
11. Stop after the smallest behavior-preserving slice that improves the graph.
|
||||
|
||||
## Output Policy
|
||||
|
||||
- **Failures**: Cycles, entrypoint reach-through, invalid configured layer edges, invalid configured forbidden edges.
|
||||
- **Warnings**: Missing headers, missing configured header clauses, shared-bucket candidates, flat-root drift, configured surface pressure.
|
||||
- **Pass**: The checked graph is acyclic and configured boundary rules hold.
|
||||
- **Human-readable spacing**: In non-JSON mode, separate section banners from diagnostic lines with a blank line and separate final summaries from the diagnostic stream with a blank line. Example: `--- DOMAIN DAG VALIDATOR ---`, blank line, `[INFO] ...`; after the last `[PASS]` / `[WARN]` / `[FAIL]`, blank line, `Result: N error(s), M warning(s)`. Do not add this spacing inside `--json` output.
|
||||
|
||||
## Review Lens
|
||||
|
||||
A good Domain DAG review asks:
|
||||
|
||||
- Is every durable responsibility owned exactly once?
|
||||
- Are public contracts smaller than the internals behind them?
|
||||
- Are peer orchestration modules reusing each other instead of extracting a lower capability?
|
||||
- Are shared buckets hiding ownership decisions?
|
||||
- Are interface surfaces becoming RPC-style bags of callbacks or methods?
|
||||
- Did decomposition improve change locality without hiding essential control flow?
|
||||
- Are validator rules hard only where they are low-noise and project-earned?
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"sourceRoots": ["scripts"],
|
||||
"entrypoints": ["scripts/validate-domain-dag.mjs"],
|
||||
"requireHeaders": true,
|
||||
"headerSeverity": "error",
|
||||
"flatRoots": true,
|
||||
"flatRootSeverity": "error",
|
||||
"sharedBucketSeverity": "error"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "validate-domain-dag",
|
||||
"description": "Validate Domain DAG architecture metadata for a repository root.",
|
||||
"args": ["root:path=."],
|
||||
"defaults": {
|
||||
"skill_dir": "~/.pi/agent/skills/domain-dag"
|
||||
},
|
||||
"template": "{skill_dir}/scripts/validate-domain-dag.mjs --root {root}"
|
||||
}
|
||||
+771
@@ -0,0 +1,771 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Domain DAG project validator
|
||||
* Domains: source graph, architecture validation, command-line diagnostics
|
||||
* Validates local import acyclicity, composition-root boundaries, shared-bucket drift, headers, and optional configured rules
|
||||
*/
|
||||
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import {
|
||||
basename,
|
||||
dirname,
|
||||
extname,
|
||||
join,
|
||||
relative,
|
||||
resolve,
|
||||
sep,
|
||||
} from "node:path";
|
||||
|
||||
const DEFAULT_CONFIG = {
|
||||
sourceRoots: ["src", "lib"],
|
||||
entrypoints: [
|
||||
"index.ts",
|
||||
"index.tsx",
|
||||
"index.js",
|
||||
"index.mjs",
|
||||
"src/index.ts",
|
||||
"src/index.tsx",
|
||||
"src/main.ts",
|
||||
"src/main.tsx",
|
||||
"lib/index.ts",
|
||||
],
|
||||
sourceExtensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".svelte"],
|
||||
ignoreDirs: [
|
||||
".git",
|
||||
".hg",
|
||||
".svn",
|
||||
"node_modules",
|
||||
"dist",
|
||||
"build",
|
||||
"coverage",
|
||||
".next",
|
||||
".nuxt",
|
||||
"target",
|
||||
"vendor",
|
||||
],
|
||||
excludePatterns: [
|
||||
"**/*.d.ts",
|
||||
"**/*.test.ts",
|
||||
"**/*.test.tsx",
|
||||
"**/*.test.js",
|
||||
"**/*.spec.ts",
|
||||
"**/*.spec.tsx",
|
||||
"**/*.spec.js",
|
||||
"**/__tests__/**",
|
||||
],
|
||||
importAliases: {},
|
||||
requireHeaders: true,
|
||||
headerPattern: "\\b(?:Domain|Domains|Zone|Zones|Owns)" + ":\\s*\\S",
|
||||
headerSeverity: "warn",
|
||||
headerRequiredClauses: [],
|
||||
headerRequiredClausesSeverity: "warn",
|
||||
flatRoots: false,
|
||||
flatRootSeverity: "warn",
|
||||
sharedBucketNames: [
|
||||
"types",
|
||||
"constants",
|
||||
"utils",
|
||||
"helpers",
|
||||
"shared",
|
||||
"common",
|
||||
],
|
||||
allowedSharedBuckets: [],
|
||||
sharedBucketSeverity: "warn",
|
||||
forbiddenEdges: [],
|
||||
layers: [],
|
||||
surfaceRules: [],
|
||||
};
|
||||
const DEFAULT_CONFIG_NAMES = ["domain-dag.json", ".domain-dag.json"];
|
||||
const MAX_LISTED_ITEMS = 30;
|
||||
|
||||
function toPosixPath(path) {
|
||||
return path.split(sep).join("/");
|
||||
}
|
||||
|
||||
function escapeRegExp(text) {
|
||||
return text.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
||||
}
|
||||
|
||||
function globToRegExp(glob) {
|
||||
const pattern = toPosixPath(glob);
|
||||
let output = "^";
|
||||
for (let index = 0; index < pattern.length; index += 1) {
|
||||
const char = pattern[index];
|
||||
if (
|
||||
char === "*" &&
|
||||
pattern[index + 1] === "*" &&
|
||||
pattern[index + 2] === "/"
|
||||
) {
|
||||
output += "(?:.*/)?";
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (char === "*" && pattern[index + 1] === "*") {
|
||||
output += ".*";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === "*") {
|
||||
output += "[^/]*";
|
||||
continue;
|
||||
}
|
||||
if (char === "?") {
|
||||
output += "[^/]";
|
||||
continue;
|
||||
}
|
||||
output += escapeRegExp(char);
|
||||
}
|
||||
return new RegExp(`${output}$`);
|
||||
}
|
||||
|
||||
function matchesAnyGlob(path, patterns) {
|
||||
const normalized = toPosixPath(path);
|
||||
return patterns.some((pattern) => globToRegExp(pattern).test(normalized));
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
json: false,
|
||||
strict: false,
|
||||
root: undefined,
|
||||
config: undefined,
|
||||
};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--json") {
|
||||
args.json = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--strict") {
|
||||
args.strict = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--root") {
|
||||
args.root = argv[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--config") {
|
||||
args.config = argv[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
args.help = true;
|
||||
continue;
|
||||
}
|
||||
if (!arg.startsWith("-") && !args.root) {
|
||||
args.root = arg;
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(
|
||||
[
|
||||
"Domain DAG validator",
|
||||
"",
|
||||
"Usage:",
|
||||
" validate-domain-dag.sh [--root <path>] [--config <path>] [--strict] [--json]",
|
||||
"",
|
||||
"Checks local source import cycles, composition-root reach-through, shared buckets, headers, and configured layer/edge rules.",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
function readJsonFile(path) {
|
||||
return JSON.parse(readFileSync(path, "utf8"));
|
||||
}
|
||||
|
||||
function findConfigPath(root, explicitConfig) {
|
||||
if (explicitConfig) return resolve(root, explicitConfig);
|
||||
for (const name of DEFAULT_CONFIG_NAMES) {
|
||||
const candidate = join(root, name);
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function loadConfig(root, explicitConfig) {
|
||||
const configPath = findConfigPath(root, explicitConfig);
|
||||
if (!configPath)
|
||||
return {
|
||||
config: { ...DEFAULT_CONFIG },
|
||||
configPath: undefined,
|
||||
};
|
||||
const loadedConfig = readJsonFile(configPath);
|
||||
return { config: { ...DEFAULT_CONFIG, ...loadedConfig }, configPath };
|
||||
}
|
||||
|
||||
function createReporter({ json, strict }) {
|
||||
const state = {
|
||||
errors: 0,
|
||||
warnings: 0,
|
||||
items: [],
|
||||
};
|
||||
const color = (code, text) =>
|
||||
json || process.env.NO_COLOR || !process.stdout.isTTY
|
||||
? text
|
||||
: `\x1b[${code}m${text}\x1b[0m`;
|
||||
const emit = (level, message, details = {}) => {
|
||||
const effectiveLevel = strict && level === "warn" ? "error" : level;
|
||||
if (effectiveLevel === "error") state.errors += 1;
|
||||
if (effectiveLevel === "warn") state.warnings += 1;
|
||||
state.items.push({ level: effectiveLevel, message, details });
|
||||
if (json) return;
|
||||
const label =
|
||||
effectiveLevel === "error"
|
||||
? color("0;31", "[FAIL]")
|
||||
: effectiveLevel === "warn"
|
||||
? color("1;33", "[WARN]")
|
||||
: effectiveLevel === "pass"
|
||||
? color("0;32", "[PASS]")
|
||||
: color("0;36", "[INFO]");
|
||||
console.log(`${label} ${message}`);
|
||||
};
|
||||
return {
|
||||
state,
|
||||
info: (message, details) => emit("info", message, details),
|
||||
pass: (message, details) => emit("pass", message, details),
|
||||
warn: (message, details) => emit("warn", message, details),
|
||||
error: (message, details) => emit("error", message, details),
|
||||
bySeverity: (severity, message, details) => {
|
||||
if (severity === "off") return;
|
||||
if (severity === "error") emit("error", message, details);
|
||||
else emit("warn", message, details);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isIgnoredDirectory(name, config) {
|
||||
return config.ignoreDirs.includes(name);
|
||||
}
|
||||
|
||||
function isSourceFile(path, root, config) {
|
||||
const relativePath = toPosixPath(relative(root, path));
|
||||
const extension = extname(path);
|
||||
if (!config.sourceExtensions.includes(extension)) return false;
|
||||
if (relativePath.endsWith(".d.ts")) return false;
|
||||
return !matchesAnyGlob(relativePath, config.excludePatterns);
|
||||
}
|
||||
|
||||
function walkSourceFiles(directory, root, config, files) {
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
const absolutePath = join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (!isIgnoredDirectory(entry.name, config))
|
||||
walkSourceFiles(absolutePath, root, config, files);
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() && isSourceFile(absolutePath, root, config))
|
||||
files.add(resolve(absolutePath));
|
||||
}
|
||||
}
|
||||
|
||||
function collectSourceFiles(root, config) {
|
||||
const files = new Set();
|
||||
for (const sourceRoot of config.sourceRoots) {
|
||||
const absoluteRoot = resolve(root, sourceRoot);
|
||||
if (!existsSync(absoluteRoot)) continue;
|
||||
const stats = statSync(absoluteRoot);
|
||||
if (stats.isFile() && isSourceFile(absoluteRoot, root, config))
|
||||
files.add(resolve(absoluteRoot));
|
||||
if (stats.isDirectory()) walkSourceFiles(absoluteRoot, root, config, files);
|
||||
}
|
||||
for (const entrypoint of config.entrypoints) {
|
||||
const absolutePath = resolve(root, entrypoint);
|
||||
if (
|
||||
existsSync(absolutePath) &&
|
||||
statSync(absolutePath).isFile() &&
|
||||
isSourceFile(absolutePath, root, config)
|
||||
)
|
||||
files.add(resolve(absolutePath));
|
||||
}
|
||||
return [...files].sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function stripSourceComments(source) {
|
||||
return source
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/(^|[^:])\/\/[^\n\r]*/gm, "$1");
|
||||
}
|
||||
|
||||
function getImportSpecifiers(source) {
|
||||
const cleaned = stripSourceComments(source);
|
||||
const specifiers = new Set();
|
||||
const patterns = [
|
||||
/(import|export)\s+(type\s+)?[^"']*?\s+from\s*["']([^"']+)["']/g,
|
||||
/import\s*["']([^"']+)["']/g,
|
||||
/import\s*\(\s*["']([^"']+)["']\s*\)/g,
|
||||
/require\s*\(\s*["']([^"']+)["']\s*\)/g,
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
for (const match of cleaned.matchAll(pattern)) {
|
||||
specifiers.add(match[match.length - 1]);
|
||||
}
|
||||
}
|
||||
return [...specifiers].filter(Boolean);
|
||||
}
|
||||
|
||||
function getImportResolutionCandidates(basePath, config) {
|
||||
const candidates = [basePath];
|
||||
for (const extension of config.sourceExtensions)
|
||||
candidates.push(`${basePath}${extension}`);
|
||||
for (const extension of config.sourceExtensions)
|
||||
candidates.push(join(basePath, `index${extension}`));
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function getImportAliasBase(root, specifier, config) {
|
||||
const aliases = config.importAliases ?? {};
|
||||
for (const [aliasPattern, targetPattern] of Object.entries(aliases)) {
|
||||
if (aliasPattern.endsWith("/*") && targetPattern.endsWith("/*")) {
|
||||
const aliasPrefix = aliasPattern.slice(0, -1);
|
||||
if (specifier.startsWith(aliasPrefix)) {
|
||||
const suffix = specifier.slice(aliasPrefix.length);
|
||||
return resolve(root, targetPattern.slice(0, -1), suffix);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (specifier === aliasPattern) return resolve(root, targetPattern);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveLocalImport(fromFile, specifier, fileSet, config, root) {
|
||||
const basePath = specifier.startsWith(".")
|
||||
? resolve(dirname(fromFile), specifier)
|
||||
: getImportAliasBase(root, specifier, config);
|
||||
if (!basePath) return undefined;
|
||||
for (const candidate of getImportResolutionCandidates(basePath, config)) {
|
||||
const normalized = resolve(candidate);
|
||||
if (fileSet.has(normalized)) return normalized;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildImportGraph(root, files, config) {
|
||||
const fileSet = new Set(files.map((file) => resolve(file)));
|
||||
const graph = new Map();
|
||||
for (const file of files) {
|
||||
const deps = [];
|
||||
const source = readFileSync(file, "utf8");
|
||||
for (const specifier of getImportSpecifiers(source)) {
|
||||
const resolved = resolveLocalImport(
|
||||
file,
|
||||
specifier,
|
||||
fileSet,
|
||||
config,
|
||||
root,
|
||||
);
|
||||
if (resolved) deps.push(toPosixPath(relative(root, resolved)));
|
||||
}
|
||||
graph.set(toPosixPath(relative(root, file)), [...new Set(deps)].sort());
|
||||
}
|
||||
return graph;
|
||||
}
|
||||
|
||||
function findCycles(graph) {
|
||||
const cycles = [];
|
||||
const visited = new Set();
|
||||
const active = [];
|
||||
const visit = (file) => {
|
||||
const activeIndex = active.indexOf(file);
|
||||
if (activeIndex !== -1) {
|
||||
cycles.push([...active.slice(activeIndex), file]);
|
||||
return;
|
||||
}
|
||||
if (visited.has(file)) return;
|
||||
visited.add(file);
|
||||
active.push(file);
|
||||
for (const dep of graph.get(file) ?? []) visit(dep);
|
||||
active.pop();
|
||||
};
|
||||
for (const file of graph.keys()) visit(file);
|
||||
return cycles;
|
||||
}
|
||||
|
||||
function getEntrypointSet(root, files, config) {
|
||||
const fileSet = new Set(files.map((file) => resolve(file)));
|
||||
const entrypoints = new Set();
|
||||
for (const entrypoint of config.entrypoints) {
|
||||
const absolutePath = resolve(root, entrypoint);
|
||||
if (fileSet.has(absolutePath))
|
||||
entrypoints.add(toPosixPath(relative(root, absolutePath)));
|
||||
}
|
||||
return entrypoints;
|
||||
}
|
||||
|
||||
function validateEntrypointReachThrough(graph, entrypoints, reporter) {
|
||||
const violations = [];
|
||||
for (const [file, deps] of graph.entries()) {
|
||||
if (entrypoints.has(file)) continue;
|
||||
for (const dep of deps)
|
||||
if (entrypoints.has(dep)) violations.push(`${file} -> ${dep}`);
|
||||
}
|
||||
if (violations.length === 0) {
|
||||
reporter.pass("Domain modules do not import configured entrypoints");
|
||||
return;
|
||||
}
|
||||
for (const violation of violations.slice(0, MAX_LISTED_ITEMS))
|
||||
reporter.error(`Domain module imports composition root: ${violation}`);
|
||||
if (violations.length > MAX_LISTED_ITEMS)
|
||||
reporter.error(
|
||||
`Additional entrypoint reach-through violations: ${violations.length - MAX_LISTED_ITEMS}`,
|
||||
);
|
||||
}
|
||||
|
||||
function validateHeaders(root, files, config, reporter) {
|
||||
if (!config.requireHeaders) return;
|
||||
const pattern = new RegExp(config.headerPattern, "im");
|
||||
const missing = [];
|
||||
const missingClauses = [];
|
||||
const requiredClauses = Array.isArray(config.headerRequiredClauses)
|
||||
? config.headerRequiredClauses
|
||||
: [];
|
||||
for (const file of files) {
|
||||
const relativePath = toPosixPath(relative(root, file));
|
||||
const sourceHead = readFileSync(file, "utf8").slice(0, 1600);
|
||||
const hasHeader = pattern.test(sourceHead);
|
||||
if (!hasHeader) {
|
||||
missing.push(relativePath);
|
||||
continue;
|
||||
}
|
||||
for (const clause of requiredClauses) {
|
||||
if (clause && !sourceHead.includes(clause))
|
||||
missingClauses.push(`${relativePath} missing '${clause}'`);
|
||||
}
|
||||
}
|
||||
if (missing.length === 0) reporter.pass("Domain headers are present");
|
||||
else {
|
||||
for (const file of missing.slice(0, MAX_LISTED_ITEMS))
|
||||
reporter.bySeverity(
|
||||
config.headerSeverity,
|
||||
`Missing domain header: ${file}`,
|
||||
);
|
||||
if (missing.length > MAX_LISTED_ITEMS)
|
||||
reporter.bySeverity(
|
||||
config.headerSeverity,
|
||||
`Additional files without domain headers: ${missing.length - MAX_LISTED_ITEMS}`,
|
||||
);
|
||||
}
|
||||
if (requiredClauses.length === 0) return;
|
||||
if (missingClauses.length === 0) {
|
||||
reporter.pass("Required domain-header clauses are present");
|
||||
return;
|
||||
}
|
||||
for (const item of missingClauses.slice(0, MAX_LISTED_ITEMS))
|
||||
reporter.bySeverity(
|
||||
config.headerRequiredClausesSeverity,
|
||||
`Domain header clause missing: ${item}`,
|
||||
);
|
||||
if (missingClauses.length > MAX_LISTED_ITEMS)
|
||||
reporter.bySeverity(
|
||||
config.headerRequiredClausesSeverity,
|
||||
`Additional missing domain-header clauses: ${missingClauses.length - MAX_LISTED_ITEMS}`,
|
||||
);
|
||||
}
|
||||
|
||||
function validateSharedBuckets(root, files, config, reporter) {
|
||||
const violations = [];
|
||||
const names = new Set(config.sharedBucketNames);
|
||||
for (const file of files) {
|
||||
const relativePath = toPosixPath(relative(root, file));
|
||||
if (matchesAnyGlob(relativePath, config.allowedSharedBuckets)) continue;
|
||||
const parts = relativePath.split("/");
|
||||
const baseName = basename(relativePath, extname(relativePath));
|
||||
if (names.has(baseName)) violations.push(`file ${relativePath}`);
|
||||
for (const part of parts.slice(0, -1))
|
||||
if (names.has(part)) violations.push(`folder ${part} in ${relativePath}`);
|
||||
}
|
||||
if (violations.length === 0) {
|
||||
reporter.pass("No shared-bucket candidates found");
|
||||
return;
|
||||
}
|
||||
for (const violation of violations.slice(0, MAX_LISTED_ITEMS))
|
||||
reporter.bySeverity(
|
||||
config.sharedBucketSeverity,
|
||||
`Shared-bucket candidate: ${violation}`,
|
||||
);
|
||||
if (violations.length > MAX_LISTED_ITEMS)
|
||||
reporter.bySeverity(
|
||||
config.sharedBucketSeverity,
|
||||
`Additional shared-bucket candidates: ${violations.length - MAX_LISTED_ITEMS}`,
|
||||
);
|
||||
}
|
||||
|
||||
function validateFlatRoots(root, files, config, reporter) {
|
||||
if (!config.flatRoots) return;
|
||||
const violations = [];
|
||||
const sourceRoots = config.sourceRoots
|
||||
.map((sourceRoot) => resolve(root, sourceRoot))
|
||||
.filter(
|
||||
(sourceRoot) =>
|
||||
existsSync(sourceRoot) && statSync(sourceRoot).isDirectory(),
|
||||
);
|
||||
for (const file of files) {
|
||||
for (const sourceRoot of sourceRoots) {
|
||||
const inside = relative(sourceRoot, file);
|
||||
if (
|
||||
inside.startsWith("..") ||
|
||||
resolve(sourceRoot, inside) !== resolve(file)
|
||||
)
|
||||
continue;
|
||||
if (toPosixPath(inside).includes("/"))
|
||||
violations.push(toPosixPath(relative(root, file)));
|
||||
}
|
||||
}
|
||||
if (violations.length === 0) {
|
||||
reporter.pass("Configured flat roots have no nested source files");
|
||||
return;
|
||||
}
|
||||
for (const violation of violations.slice(0, MAX_LISTED_ITEMS))
|
||||
reporter.bySeverity(
|
||||
config.flatRootSeverity,
|
||||
`Nested source file in flat root: ${violation}`,
|
||||
);
|
||||
if (violations.length > MAX_LISTED_ITEMS)
|
||||
reporter.bySeverity(
|
||||
config.flatRootSeverity,
|
||||
`Additional nested flat-root files: ${violations.length - MAX_LISTED_ITEMS}`,
|
||||
);
|
||||
}
|
||||
|
||||
function getLayerForFile(file, layers) {
|
||||
for (let index = 0; index < layers.length; index += 1) {
|
||||
const layer = layers[index];
|
||||
const rank = Number.isFinite(layer.rank) ? layer.rank : index;
|
||||
if (matchesAnyGlob(file, layer.files ?? []))
|
||||
return { name: layer.name ?? `layer-${index}`, rank };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function validateLayers(graph, config, reporter) {
|
||||
if (!Array.isArray(config.layers) || config.layers.length === 0) return;
|
||||
const violations = [];
|
||||
for (const [file, deps] of graph.entries()) {
|
||||
const fromLayer = getLayerForFile(file, config.layers);
|
||||
if (!fromLayer) continue;
|
||||
for (const dep of deps) {
|
||||
const toLayer = getLayerForFile(dep, config.layers);
|
||||
if (toLayer && fromLayer.rank < toLayer.rank)
|
||||
violations.push(
|
||||
`${file} (${fromLayer.name}) -> ${dep} (${toLayer.name})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (violations.length === 0) {
|
||||
reporter.pass("Configured layer direction holds");
|
||||
return;
|
||||
}
|
||||
for (const violation of violations.slice(0, MAX_LISTED_ITEMS))
|
||||
reporter.error(`Layer direction violation: ${violation}`);
|
||||
if (violations.length > MAX_LISTED_ITEMS)
|
||||
reporter.error(
|
||||
`Additional layer violations: ${violations.length - MAX_LISTED_ITEMS}`,
|
||||
);
|
||||
}
|
||||
|
||||
function collectConfiguredGlobs(config) {
|
||||
const globs = [];
|
||||
const add = (owner, value) => {
|
||||
if (!value) return;
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
for (const item of values) {
|
||||
if (typeof item === "string") globs.push({ owner, value: item });
|
||||
}
|
||||
};
|
||||
add("sourceRoots", config.sourceRoots);
|
||||
add("entrypoints", config.entrypoints);
|
||||
add("allowedSharedBuckets", config.allowedSharedBuckets);
|
||||
for (const edge of config.forbiddenEdges ?? []) {
|
||||
add("forbiddenEdges.from", edge.from);
|
||||
add("forbiddenEdges.to", edge.to);
|
||||
}
|
||||
for (const layer of config.layers ?? [])
|
||||
add(`layers.${layer.name ?? "unnamed"}`, layer.files);
|
||||
for (const rule of config.surfaceRules ?? [])
|
||||
add(`surfaceRules.${rule.name ?? "unnamed"}`, rule.files);
|
||||
return globs;
|
||||
}
|
||||
|
||||
function validateConfigSyntax(config, reporter) {
|
||||
const unsupportedBraceGlobs = collectConfiguredGlobs(config).filter(
|
||||
({ value }) => /[{}]/.test(value),
|
||||
);
|
||||
for (const glob of unsupportedBraceGlobs.slice(0, MAX_LISTED_ITEMS)) {
|
||||
reporter.warn(
|
||||
`Unsupported brace glob syntax in ${glob.owner}: ${glob.value}; expand it into separate glob entries`,
|
||||
);
|
||||
}
|
||||
if (unsupportedBraceGlobs.length > MAX_LISTED_ITEMS)
|
||||
reporter.warn(
|
||||
`Additional unsupported brace globs: ${unsupportedBraceGlobs.length - MAX_LISTED_ITEMS}`,
|
||||
);
|
||||
}
|
||||
|
||||
function validateSurfaceRules(root, files, config, reporter) {
|
||||
if (!Array.isArray(config.surfaceRules) || config.surfaceRules.length === 0)
|
||||
return;
|
||||
for (const rule of config.surfaceRules) {
|
||||
const max = Number.isFinite(rule.max) ? rule.max : undefined;
|
||||
if (max === undefined) continue;
|
||||
const filesGlob = Array.isArray(rule.files)
|
||||
? rule.files
|
||||
: rule.files
|
||||
? [rule.files]
|
||||
: ["**/*"];
|
||||
const violations = [];
|
||||
for (const file of files) {
|
||||
const relativePath = toPosixPath(relative(root, file));
|
||||
if (!matchesAnyGlob(relativePath, filesGlob)) continue;
|
||||
const source = readFileSync(file, "utf8");
|
||||
let count = 0;
|
||||
if (rule.metric === "lines") {
|
||||
count = source.split(/\r?\n/).length;
|
||||
} else {
|
||||
if (!rule.pattern) continue;
|
||||
const pattern = new RegExp(rule.pattern, rule.flags ?? "g");
|
||||
const matches = new Set();
|
||||
for (const match of source.matchAll(pattern)) {
|
||||
matches.add(match[1] ?? match[0]);
|
||||
}
|
||||
count = matches.size;
|
||||
}
|
||||
if (count > max)
|
||||
violations.push(`${relativePath} measured ${count}/${max}`);
|
||||
}
|
||||
if (violations.length === 0) {
|
||||
reporter.pass(
|
||||
rule.passMessage ??
|
||||
`Surface rule holds: ${rule.name ?? rule.pattern ?? rule.metric}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
for (const violation of violations.slice(0, MAX_LISTED_ITEMS))
|
||||
reporter.bySeverity(
|
||||
rule.severity ?? "warn",
|
||||
`${rule.message ?? "Wide interface surface"}: ${violation}`,
|
||||
);
|
||||
if (violations.length > MAX_LISTED_ITEMS)
|
||||
reporter.bySeverity(
|
||||
rule.severity ?? "warn",
|
||||
`Additional surface-rule violations: ${violations.length - MAX_LISTED_ITEMS}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateForbiddenEdges(graph, config, reporter) {
|
||||
if (
|
||||
!Array.isArray(config.forbiddenEdges) ||
|
||||
config.forbiddenEdges.length === 0
|
||||
)
|
||||
return;
|
||||
const violations = [];
|
||||
for (const [file, deps] of graph.entries()) {
|
||||
for (const dep of deps) {
|
||||
for (const rule of config.forbiddenEdges) {
|
||||
const fromMatches =
|
||||
!rule.from ||
|
||||
matchesAnyGlob(
|
||||
file,
|
||||
Array.isArray(rule.from) ? rule.from : [rule.from],
|
||||
);
|
||||
const toMatches =
|
||||
!rule.to ||
|
||||
matchesAnyGlob(dep, Array.isArray(rule.to) ? rule.to : [rule.to]);
|
||||
if (fromMatches && toMatches)
|
||||
violations.push({
|
||||
severity: rule.severity ?? "error",
|
||||
message: rule.message ?? "Forbidden edge",
|
||||
edge: `${file} -> ${dep}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (violations.length === 0) {
|
||||
reporter.pass("Configured forbidden edges are absent");
|
||||
return;
|
||||
}
|
||||
for (const violation of violations.slice(0, MAX_LISTED_ITEMS))
|
||||
reporter.bySeverity(
|
||||
violation.severity,
|
||||
`${violation.message}: ${violation.edge}`,
|
||||
);
|
||||
if (violations.length > MAX_LISTED_ITEMS)
|
||||
reporter.error(
|
||||
`Additional forbidden-edge violations: ${violations.length - MAX_LISTED_ITEMS}`,
|
||||
);
|
||||
}
|
||||
|
||||
function countEdges(graph) {
|
||||
let count = 0;
|
||||
for (const deps of graph.values()) count += deps.length;
|
||||
return count;
|
||||
}
|
||||
|
||||
function runValidation(args) {
|
||||
const root = resolve(
|
||||
args.root ?? process.env.DOMAIN_DAG_ROOT ?? process.cwd(),
|
||||
);
|
||||
const { config, configPath } = loadConfig(root, args.config);
|
||||
const reporter = createReporter(args);
|
||||
const files = collectSourceFiles(root, config);
|
||||
const graph = buildImportGraph(root, files, config);
|
||||
const cycles = findCycles(graph);
|
||||
if (!args.json) console.log("--- DOMAIN DAG VALIDATOR ---\n");
|
||||
reporter.info(`Root: ${root}`);
|
||||
if (configPath) reporter.info(`Config: ${configPath}`);
|
||||
validateConfigSyntax(config, reporter);
|
||||
reporter.info(
|
||||
`Source files ${files.length}, local edges ${countEdges(graph)}`,
|
||||
);
|
||||
if (files.length === 0)
|
||||
reporter.warn("No source files found in configured roots");
|
||||
if (cycles.length === 0)
|
||||
reporter.pass("Local source import graph is acyclic");
|
||||
for (const cycle of cycles.slice(0, MAX_LISTED_ITEMS))
|
||||
reporter.error(`Import cycle: ${cycle.join(" -> ")}`);
|
||||
if (cycles.length > MAX_LISTED_ITEMS)
|
||||
reporter.error(
|
||||
`Additional import cycles: ${cycles.length - MAX_LISTED_ITEMS}`,
|
||||
);
|
||||
validateEntrypointReachThrough(
|
||||
graph,
|
||||
getEntrypointSet(root, files, config),
|
||||
reporter,
|
||||
);
|
||||
validateHeaders(root, files, config, reporter);
|
||||
validateSharedBuckets(root, files, config, reporter);
|
||||
validateFlatRoots(root, files, config, reporter);
|
||||
validateLayers(graph, config, reporter);
|
||||
validateSurfaceRules(root, files, config, reporter);
|
||||
validateForbiddenEdges(graph, config, reporter);
|
||||
if (args.json) console.log(JSON.stringify(reporter.state, null, 2));
|
||||
else
|
||||
console.log(
|
||||
`\nResult: ${reporter.state.errors} error(s), ${reporter.state.warnings} warning(s)`,
|
||||
);
|
||||
return reporter.state.errors === 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help) {
|
||||
printHelp();
|
||||
return 0;
|
||||
}
|
||||
return runValidation(args);
|
||||
}
|
||||
|
||||
try {
|
||||
process.exitCode = main();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`RUNTIME ERROR: ${message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P)"
|
||||
exec node "$SCRIPT_DIR/validate-domain-dag.mjs" "$@"
|
||||
@@ -0,0 +1,240 @@
|
||||
---
|
||||
name: telegram-bot
|
||||
description: Local Telegram Bot API reference lookup for bot transport, updates, forum topics, Rich Messages, media/files, callbacks, reactions, and Bot API method capability checks.
|
||||
metadata:
|
||||
version: 1.0.0
|
||||
---
|
||||
|
||||
# Telegram Bot API Reference Skill
|
||||
|
||||
Use this skill when implementation or review depends on Telegram Bot API details: update shapes, method parameters, response objects, forum topic/thread support, Rich Messages, files/media, callback queries, reactions, or Bot API error semantics.
|
||||
|
||||
The local reference is `api.md` in this skill directory. Treat it as vendored reference material: search it, cite the relevant anchor or section in reasoning, but do not reshape it for prose style.
|
||||
|
||||
## Lookup Protocol
|
||||
|
||||
Use the reference through multiple indexing dimensions. Do not rely on only one navigation mode.
|
||||
|
||||
1. Start with the task-shaped index below to choose a likely region.
|
||||
2. Use line ranges when the region is known. Line references use an `L` prefix (`L393`, `L1000`) to make them visually distinct from anchors, ids, and limits. When calling `read`, drop the prefix: `L393` means `offset: 393`.
|
||||
3. Use substring search for exact fields, methods, and error text. Prefer exact symbols with `rg`, for example `rg "message_thread_id|sendChatAction|ForumTopic" .agents/skills/telegram-bot/api.md`.
|
||||
4. Use anchors as a semantic cross-check. Bot API method/type names usually map to Markdown anchors by lowercasing the name: `sendRichMessageDraft` -> `#sendrichmessagedraft`, `MessageReactionUpdated` -> `#messagereactionupdated`.
|
||||
5. Read only the relevant section around the match. If a section is large, read the first lines for the field table, then search inside the section for the exact field.
|
||||
6. Verify both sides of a feature:
|
||||
- Inbound shape: `Update`, `Message`, `CallbackQuery`, reaction/update object, etc.
|
||||
- Outbound capability: send/edit/delete/answer method parameters and return type.
|
||||
7. Distinguish documented capability from live-client behavior. If the reference says a parameter exists but client UX is uncertain, mark it as a live/manual verification item.
|
||||
8. Keep project docs concise. Link to this skill/reference for capability evidence; do not duplicate large Bot API tables in operator docs.
|
||||
|
||||
## Reading By Line Range
|
||||
|
||||
`api.md` is large vendored reference material. Use these line bands to jump directly to useful blocks:
|
||||
|
||||
- `L1-L132` — Recent Bot API changelog entries; useful for new capabilities such as Rich Messages and guest mode.
|
||||
- `L133-L179` — Authorization, request formats, response envelope, and local Bot API server behavior.
|
||||
- `L180-L285` — Updates, `Update`, `getUpdates`, webhooks, and webhook info.
|
||||
- `L286-L318` — Start of available types, including `User`.
|
||||
- `L319-L392` — `Chat` and `ChatFullInfo`.
|
||||
- `L393-L614` — `Message`, message ids, inaccessible messages, entities, quotes, replies, origins, and reply parameters.
|
||||
- `L668-L805` — Core media message objects: photo, animation, audio, document, live photo, video, video note, voice.
|
||||
- `L1267-L1302` — Forum-topic service message objects.
|
||||
- `L1493-L1569` — Link preview, direct-message/topic helpers, profiles, and `File`.
|
||||
- `L1573-L1751` — Keyboards, inline keyboard buttons, callback queries, force replies.
|
||||
- `L1821-L1957` — Chat member updates, members, join requests, permissions.
|
||||
- `L2142-L2219` — Reaction types/counts, reaction updates, and `ForumTopic`.
|
||||
- `L2764-L2797` — Guest replies, prepared inline messages, response parameters.
|
||||
- `L2798-L2956` — `InputMedia*` variants and `InputFile`.
|
||||
- `L3054-L3124` — Sending files notes, colors, and inline mode object prelude.
|
||||
- `L3125-L3836` — Main send methods: `sendMessage`, formatting, media sends, albums, polls, drafts, chat actions, reactions.
|
||||
- `L3847-L4307` — File download, chat moderation/management, forum topic lifecycle.
|
||||
- `L4308-L4834` — Callback/guest answers, bot metadata, gifts/business/story/web app/prepared inline methods.
|
||||
- `L4835-L5020` — Updating/deleting messages and reaction deletion.
|
||||
- `L5021-L5242` — Stickers.
|
||||
- `L5243-L6116` — Rich Messages formatting, send/draft methods, `RichText*`, and `RichBlock*`.
|
||||
- `L6117-L6662` — Inline mode, inline query answers/results, and `InputMessageContent` variants.
|
||||
- `L6663-L7054` — Payments and stars.
|
||||
- `L7055-L7230` — Telegram Passport.
|
||||
- `L7231-L7306` — Games.
|
||||
|
||||
For exact section boundaries, run:
|
||||
|
||||
```bash
|
||||
rg -n "^(###|####) " .agents/skills/telegram-bot/api.md
|
||||
```
|
||||
|
||||
## Freshness-First Lens
|
||||
|
||||
The highest-value use of this skill is not generic Telegram bot knowledge. Models often already know older Bot API concepts. The local `api.md` is most valuable for **new or recently changed Bot API surface** that may be absent, stale, or hallucinated in model training data.
|
||||
|
||||
Before relying on prior knowledge, check the freshness layer when work touches newly evolving areas such as Rich Messages, guest mode, managed bots, forum/thread behavior, reactions, drafts, business/gift/story APIs, paid media, or new update fields.
|
||||
|
||||
### Freshness protocol
|
||||
|
||||
1. Read the recent changelog first: `L1-L132` (`offset: 1, limit: 132`).
|
||||
2. Extract the exact new symbols mentioned there: method names, class names, fields, parameters.
|
||||
3. Search each symbol in the full reference before designing code: `rg -n "sendRichMessageDraft|guest_query_id|message_thread_id" .agents/skills/telegram-bot/api.md`.
|
||||
4. Compare old intuition against the local reference. If they differ, trust `api.md` and note the delta.
|
||||
5. When adding support for a fresh symbol, verify all three surfaces when applicable:
|
||||
- Changelog mention: what changed and in which Bot API version.
|
||||
- Type/method definition: exact field/parameter shape.
|
||||
- Runtime route: which `Update` or method response can carry it.
|
||||
6. Treat unobserved client behavior as live-gated even when the Bot API surface is documented.
|
||||
|
||||
### Recent-surface index
|
||||
|
||||
- `L1-L35` — Bot API 10.1, Rich Messages, join request queries, poll media links.
|
||||
- `L36-L91` — Bot API 10.0, guest mode, reaction deletion methods, live photos, managed bot access settings, empty drafts.
|
||||
- `L92-L132` — Bot API 9.6, managed bots, prepared keyboard buttons, poll/checklist/date-time changes.
|
||||
|
||||
Use this lens when a task asks “does Telegram support X now?”, “why is this update field unknown?”, “can we use this new method?”, or when a capability sounds newer than common bot knowledge.
|
||||
|
||||
## Task-Oriented Lookup Recipes
|
||||
|
||||
Use these recipes when the user asks a product/runtime question rather than naming an exact Bot API symbol.
|
||||
|
||||
### Preserve forum topic/thread routing
|
||||
|
||||
1. Search: `rg -n "message_thread_id|is_topic_message|ForumTopic" .agents/skills/telegram-bot/api.md`.
|
||||
2. Read `Message`: `L393-L532` (`offset: 393, limit: 140`).
|
||||
3. Read forum topic objects: `L1267-L1306` and `L2142-L2231`.
|
||||
4. Read relevant outbound methods: `sendMessage` at `L3142`, media sends at `L3385-L3630`, drafts/actions at `L3778-L3805`, Rich sends at `L5545-L5574`.
|
||||
5. Treat missing thread metadata on secondary update types as a reason to use stored message ownership rather than inventing fields.
|
||||
|
||||
### Route callbacks from inline buttons
|
||||
|
||||
1. Read inline keyboard/button types: `L1670-L1739`.
|
||||
2. Read `CallbackQuery`: `L1735-L1759`.
|
||||
3. Read `answerCallbackQuery`: `L4308-L4327`.
|
||||
4. Cross-check whether callback `message` has enough chat/thread metadata; if not, join with local message ownership.
|
||||
|
||||
### Handle reactions safely
|
||||
|
||||
1. Read reaction types and updates: `L2142-L2221`.
|
||||
2. Search: `rg -n "message_reaction|allowed_updates|deleteMessageReaction|setMessageReaction" .agents/skills/telegram-bot/api.md`.
|
||||
3. Read `getUpdates` allowed updates: `L220-L234`.
|
||||
4. Read reaction methods: `L3806-L3825` and `L5000-L5021`.
|
||||
5. Check admin/allowed-update requirements before assuming reactions arrive.
|
||||
|
||||
### Send or download files/media
|
||||
|
||||
1. Read upload/download contracts: `L2953-L2977` and `L3847-L3858`.
|
||||
2. Read local Bot API server limits: `L163-L179`.
|
||||
3. Read the concrete send method: photos `L3385`, documents `L3462`, voice `L3545`, albums `L3614`, stickers `L5082`.
|
||||
4. For inbound attachments, map `Message` media fields to `File`/`getFile` before modeling download behavior.
|
||||
|
||||
### Use Rich Messages and drafts
|
||||
|
||||
1. Read Rich Message overview and formatting examples: `L5243-L5352`.
|
||||
2. Read send/draft methods: `L5545-L5574`.
|
||||
3. Read `RichText`/`RichBlock` unions: `L5575-L5619` and `L5880-L5924`.
|
||||
4. Search for the exact primitive: `rg -n "RichBlockThinking|RichBlockMathematicalExpression|RichBlockPreformatted|RichBlockDetails" .agents/skills/telegram-bot/api.md`.
|
||||
5. Mark client rendering/draft UX as live verification unless already observed.
|
||||
|
||||
### Answer guest or inline queries
|
||||
|
||||
1. Read guest message fields on `Message`: `L393-L532`, then search `guest_query_id`.
|
||||
2. Read `SentGuestMessage`: `L2764-L2778`.
|
||||
3. Read `answerGuestQuery`: `L4322-L4331`.
|
||||
4. For inline mode, read `L6117-L6186` and `InputMessageContent` at `L6553`.
|
||||
|
||||
## Cross-Cutting Field Matrix
|
||||
|
||||
| Field/capability | Read first | Then verify on methods |
|
||||
| --- | --- | --- |
|
||||
| `message_thread_id` | `Message` `L393-L514`, `ForumTopic` `L2210-L2219` | `sendMessage`, media sends, `sendMediaGroup`, `sendMessageDraft`, `sendChatAction`, Rich sends/drafts |
|
||||
| `reply_parameters` | `ReplyParameters` `L600-L615` | Send/copy/forward methods that attach replies |
|
||||
| `reply_markup` | `InlineKeyboardMarkup` `L1670-L1677` | Send/edit methods and callback routing |
|
||||
| `rich_message` | `Message` `L393-L514`, `RichMessage` `L5525-L5533` | `sendRichMessage`, `sendRichMessageDraft`, `editMessageText` |
|
||||
| `guest_query_id` | `Message` `L393-L514`, `SentGuestMessage` `L2764-L2771` | `answerGuestQuery` |
|
||||
| `allowed_updates` | `getUpdates` `L220-L234`, `setWebhook` `L235-L257` | Reaction/chat-member update assumptions |
|
||||
| File id/path | `File` `L1560-L1569`, `InputFile` `L2953-L2956` | `getFile`, concrete send methods, local Bot API server limits |
|
||||
| Reactions | `ReactionType*` / `MessageReaction*` `L2142-L2209` | `setMessageReaction`, `deleteMessageReaction`, `deleteAllMessageReactions` |
|
||||
|
||||
## Risk And Live-Verification Index
|
||||
|
||||
Treat these as high-friction zones where the reference is necessary but may not be sufficient:
|
||||
|
||||
- Polling/webhook exclusivity: `getUpdates` cannot run while a webhook is set.
|
||||
- `allowed_updates`: reactions and chat-member style updates often require explicit opt-in and sometimes admin rights.
|
||||
- Forum topics: documented `message_thread_id` support does not prove every update shape carries thread context.
|
||||
- Deleted/stale topics: classify errors narrowly and keep live smoke coverage for actual Telegram error text.
|
||||
- Rich drafts: Bot API method existence does not settle per-client draft UX.
|
||||
- File transport: cloud Bot API and local Bot API server have materially different upload/download limits.
|
||||
- Callback/reaction routing: if an update lacks target metadata, prefer durable sent-message ownership over guessing.
|
||||
|
||||
## Search Synonym Index
|
||||
|
||||
- Topic/forum/thread: `ForumTopic`, `message_thread_id`, `is_topic_message`, `createForumTopic`.
|
||||
- Button/menu/callback: `InlineKeyboardMarkup`, `InlineKeyboardButton`, `CallbackQuery`, `answerCallbackQuery`, `callback_data`.
|
||||
- Attachment/file/download/upload: `InputFile`, `File`, `getFile`, `file_id`, `file_path`, `sendDocument`, `sendPhoto`.
|
||||
- Draft/streaming preview: `sendMessageDraft`, `sendRichMessageDraft`, `RichBlockThinking`.
|
||||
- Rich Markdown/native rendering: `RichMessage`, `InputRichMessage`, `RichText`, `RichBlock`, `sendRichMessage`.
|
||||
- Reaction/emoji shortcut: `ReactionType`, `MessageReactionUpdated`, `setMessageReaction`, `deleteMessageReaction`.
|
||||
- Guest/inline response: `guest_message`, `guest_query_id`, `answerGuestQuery`, `InlineQuery`, `InputMessageContent`.
|
||||
|
||||
## Anchor And Topic Index
|
||||
|
||||
### Transport And Request Basics
|
||||
|
||||
- `#authorizing-your-bot` — Bot token basics.
|
||||
- `#making-requests` — HTTP methods, JSON/form/multipart request formats, response envelope.
|
||||
- `#using-a-local-bot-api-server` — Local server behavior, larger upload/download limits, local file paths.
|
||||
|
||||
### Updates And Routing
|
||||
|
||||
- `#getting-updates` — Long polling vs webhooks.
|
||||
- `#update` — Top-level inbound update union.
|
||||
- `#getupdates` — Long polling method and `allowed_updates`.
|
||||
- `#setwebhook` / `#deletewebhook` — Webhook setup and removal.
|
||||
- `#message` — Main message object; check `message_thread_id`, `is_topic_message`, media, `rich_message`, guest fields.
|
||||
- `#callbackquery` — Button callback payload and callback message metadata.
|
||||
- `#messagereactionupdated` / `#messagereactioncountupdated` — Reaction updates and their available routing fields.
|
||||
|
||||
### Chats, Forums, And Thread Targets
|
||||
|
||||
- `#chat` / `#chatfullinfo` — Chat shape, including forum-related fields.
|
||||
- `#forumtopic` — Forum topic response object.
|
||||
- `#createforumtopic` — Topic provisioning and returned `message_thread_id`.
|
||||
- `#editforumtopic`, `#closeforumtopic`, `#reopenforumtopic`, `#deleteforumtopic`, `#unpinallforumtopicmessages` — Topic lifecycle.
|
||||
- Search `message_thread_id` — Thread target support across send, edit, media, draft, and action methods.
|
||||
|
||||
### Sending, Editing, Drafts, And Chat Actions
|
||||
|
||||
- `#sendmessage` — Text messages and thread targeting.
|
||||
- `#editmessagetext` — Editing text/rich messages.
|
||||
- `#deletemessage` / `#deletemessages` — Deletion semantics.
|
||||
- `#sendchataction` — Typing/upload action support, including thread targeting.
|
||||
- `#sendmessagedraft` — Plain draft behavior.
|
||||
- `#sendrichmessage` / `#sendrichmessagedraft` — Native Rich Message send/streaming draft APIs.
|
||||
|
||||
### Rich Messages
|
||||
|
||||
- `#rich-messages` — Bot API changelog entry for Rich Messages.
|
||||
- `#richmessage` / `#inputrichmessage` / `#inputrichmessagecontent` — Rich message payload objects.
|
||||
- `#richblock` / `#richtext` — Rich block/text unions.
|
||||
- Search `RichBlockThinking`, `RichBlockMathematicalExpression`, `RichBlockPreformatted`, `RichBlockTable`, `RichBlockDetails` for specific rendering primitives.
|
||||
|
||||
### Files, Media, And Albums
|
||||
|
||||
- `#inputfile` — Upload contract.
|
||||
- `#getfile` — File download metadata.
|
||||
- `#sendphoto`, `#senddocument`, `#sendvoice`, `#sendaudio`, `#sendvideo`, `#sendanimation`, `#sendsticker` — Common media sends.
|
||||
- `#sendmediagroup` — Album/grouped media behavior.
|
||||
- Search `InputMedia` for per-media payload variants.
|
||||
|
||||
### Buttons, Inline Mode, Guest Replies
|
||||
|
||||
- `#inlinekeyboardmarkup` / `#inlinekeyboardbutton` — Inline button markup.
|
||||
- `#answercallbackquery` — Callback acknowledgement.
|
||||
- `#inlinequery`, `#answerinlinequery`, `#inputmessagecontent` — Inline mode.
|
||||
- `#answerguestquery` / `#sentguestmessage` — Guest message replies.
|
||||
|
||||
### Errors And Edge Semantics
|
||||
|
||||
- `#responseparameters` — Retry/migration hints in failed responses.
|
||||
- Search exact Telegram error text when modeling stale topic, deleted message, migration, or permission behavior.
|
||||
- Prefer narrow error classification in code; broad string matching should be justified and covered by tests.
|
||||
|
||||
## Output Expectations
|
||||
|
||||
When this skill informs a code or documentation change, summarize the Bot API evidence in one sentence and name the anchor or symbol used. Example: `Bot API evidence: sendChatAction accepts message_thread_id, so chat actions can preserve forum targets.`
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user