Contributor and maintainer guide to how Sentinel is structured and how a scan runs. This document describes the system as it exists today — not a roadmap. For user-facing install and usage, see README.md.
Sentinel is a static analysis tool for TypeScript and JavaScript frontends. It walks a source tree, parses each file with the TypeScript compiler API, extracts HTTP client call sites (fetch, axios, ky, and similar patterns), resolves URLs where possible, and runs built-in rules against the resulting ApiCall[]. When ScanConfig.contractSource points at a local OpenAPI v3 JSON file, an optional contract-check phase matches calls to backend routes and diffs statically resolvable request bodies against the spec schema.
The public entry point is scan(config) in packages/core/src/runner/scanner.ts, which returns a ScanResult (API calls, findings, diagnostics, stats). The CLI (packages/cli/) is a thin wrapper: load config, parse args, call scan(), format output, set exit codes. All analysis logic lives in @sentinel-scan/core.
| Package | Path | Role today |
|---|---|---|
@sentinel-scan/core |
packages/core/ |
Analysis engine. Published on npm. Zero runtime dependencies (TypeScript is a peer). No stdout, no CLI flags, no config file loading. |
@sentinel-scan/cli |
packages/cli/ |
Terminal interface. Published on npm. Depends only on core. Config loading, arg parsing, formatters, exit codes. |
@sentinel-scan/ai |
packages/ai/ |
Placeholder. "private": true. Not implemented. |
@sentinel-scan/cloud-sdk |
packages/cloud-sdk/ |
Placeholder. "private": true. Not implemented. |
sentinel-vscode |
packages/vscode/ |
Shell package. "private": true. Not implemented. |
sentinel-action |
packages/github-action/ |
Stub GitHub Action. "private": true. Not implemented. |
| Dev scripts | scripts/ |
Learning and debugging tools (e.g. scripts/parse-ast.ts). Not shipped in any npm package. |
Core is designed to be usable programmatically (CI scripts, future VS Code extension, cloud upload) without pulling in terminal I/O. It accepts a ScanConfig object and a Logger interface — never console.* directly. The CLI owns filesystem config discovery, human-readable output, and process exit semantics. Dependency flow is one-way: cli → core, never the reverse. ESLint enforces this boundary (see CONTRIBUTING.md).
Orchestration lives in scan() in packages/core/src/runner/scanner.ts. Phases run in this order:
scan(config)
│
├─ Phase 1: File discovery scanFiles()
├─ Phase 2: Read + extract readFileContent() → extractApiCalls() (per file)
├─ Phase 3: URL resolution resolveUrls()
├─ Phase 4: Rule execution executeRules()
├─ Phase 4.5: Contract check runContractCheck() (optional)
└─ Phase 5: Result assembly buildResult()
Reporting is not part of scan(). The CLI formats the returned ScanResult after scan() completes (see Reporting).
Module: packages/core/src/scan/file-scanner.ts
- Recursively walks
config.rootDirusingnode:fs/promises(hand-rolled, no fast-glob). - Applies
include/excludeglob patterns fromScanConfig. - Reads and respects
.gitignorepatterns under the root. - Does not follow symlinks.
- Returns
Result<ScannedFile[], FileScannerError>. On failure, scanner emits a singleresolve-errordiagnostic and returns an empty result.
Each ScannedFile carries absolutePath, relativePath (POSIX-style), and extension.
Read: packages/core/src/parse/file-reader.ts
readFileContent(absolutePath)returnsResult<string, FileReaderError>.- Failures are isolated per file: one unreadable file produces a diagnostic and the scan continues.
Extract: packages/core/src/parse/api-extractor.ts
- Parses source inline via
ts.createSourceFile(does not callparseSourceFile()— that helper is for dev scripts only). - Runs
getSyntacticDiagnostics(); syntax issues becomeunsupported-syntaxScanDiagnostics. Extraction still proceeds on a partial AST. - Walks the AST with a visitor (
ts.forEachChild) to find call expressions matching known HTTP client patterns:fetch,axios/axios.get/ config-object calls,ky, generic instance methods (.get,.post, etc.),XMLHttpRequest()call expressions. - For each call, extracts method, URL (with
UrlKind: string-literal, template-literal, identifier, etc.), error-handler presence, and request body when statically resolvable. - Returns
ApiCall[]for the file.
If extractApiCalls throws unexpectedly, the scanner catches it, records a parse-error diagnostic, and continues with the next file.
Module: packages/core/src/resolve/url-resolver.ts
- Enriches
ApiCall.resolvedUrlusing optionalconfig.baseUrlandconfig.tsConfigPath(for TypeScript path alias resolution). - Operates on the full
ApiCall[]from all files. - Does not emit ScanDiagnostics today (resolution failures are silent at the diagnostic layer).
Module: packages/core/src/rules/ via executeRules() in scanner.ts
- Iterates
config.rules(rule ID → severity or'off'). - Looks up each ID in
BUILT_IN_RULES(packages/core/src/rules/index.ts). - Built-in rules today:
no-hardcoded-url,missing-error-handleronly. - Unknown rule IDs produce a
config-warningdiagnostic and are skipped. - Each rule implements
Rule.check(calls, context)and returnsFinding[]. - If a rule throws, the error is caught and recorded as a
rule-errordiagnostic; other rules continue. api-contract-mismatchis intentionally skipped here — it is orchestrated in Phase 4.5 whencontractSourceis set.
Module: packages/core/src/contract/contract-check.ts
Runs only when:
config.contractSourceis defined, andconfig.rules['api-contract-mismatch']is not'off'.
See Section 4 for detail.
buildResult() combines apiCalls, findings, diagnostics, and computed ScanStats (file counts, finding counts by severity, duration) into a ScanResult.
scan() is designed not to throw for expected failures (unreadable files, parse errors, rule crashes, bad config). Those become ScanDiagnostic entries and the scan continues. Only unexpected internal bugs propagate as exceptions. This contract is documented in the scanner header and tested in packages/core/src/runner/scanner.test.ts.
| Layer | Location | Used by |
|---|---|---|
| Core reporters | packages/core/src/report/ — TerminalReporter, JsonReporter, shared format helpers in report/format/ |
Programmatic consumers, tests |
| CLI formatters | packages/cli/src/formatters/ — text, json, sarif |
sentinel scan command |
The CLI does not import core reporters. It calls format(result, outputFormat) from its own formatter registry after scan() returns. See Known architectural gaps.
The newest and most distinctive part of Sentinel. It compares frontend request bodies against a backend OpenAPI spec for matched routes.
Each stage is a standalone, pure function with its own tests and no shared mutable state:
| Module | File | Input → Output |
|---|---|---|
| Parser | openapi-parser.ts |
Spec file path → Result<BackendRoute[], OpenApiParseError> |
| Matcher | route-matcher.ts |
ApiCall[] + BackendRoute[] → MatchResult[] |
| Differ | body-diff.ts |
ApiCall[] + MatchResult[] + routes → ContractDiffResult[] |
| Orchestrator | contract-check.ts |
Wires the above → Finding[] |
This layout allows each stage to be tested independently (openapi-parser.test.ts, route-matcher.test.ts, body-diff.test.ts) and composed without coupling to scan() internals.
Parser (openapi-parser.ts)
- Reads a local JSON file only. Rejects
.yaml/.ymlextensions explicitly. - Parses OpenAPI v3 paths and methods into normalized
BackendRoute[]with resolvableBodyShapewhere possible. $ref,oneOf, and other unresolvable schema shapes mark the route body as unresolvable rather than crashing.- Does not fetch remote URLs. Does not support Swagger 2 / OpenAPI v2.
Matcher (route-matcher.ts)
- Matches by HTTP method + path segment count and pattern (
{id}params vs static segments vs template-literal${…}wildcards). - v1 limitations (documented in the file header):
- Query strings are not stripped (
/users/1?page=1will not match{id}). - Trailing slashes are not normalized (
/users/vs/usersdiffer).
- Query strings are not stripped (
- Returns per-call status:
matched,unmatched, orunresolvable(dynamic URL, identifier URL, etc.).
Differ (body-diff.ts)
- Compares request body shapes only — no response diffing.
- Re-parses the call's request body string via
ts.createSourceFileand walks object literal properties. - Flags missing required fields, unexpected fields, and literal type mismatches.
- Returns
not-diffablewhen the body is dynamic, the route has no schema, or the schema was unresolvable. - Does not produce Findings — only structured diff results.
Orchestrator (contract-check.ts)
- Calls parse → match → diff in sequence.
- Converts
discrepancies-founddiff results intoFinding[]withruleId: 'api-contract-mismatch'. - Does not surface Findings for unmatched calls, unresolvable URLs, or not-diffable bodies — those are logged at debug level only.
- OpenAPI parse failure →
config-warningdiagnostic; scan continues without contract findings.
Contract checking is multi-phase orchestration (parse spec, match routes, diff bodies), not a single-pass Rule.check() over ApiCall[]. Severity is still configured via ScanConfig.rules['api-contract-mismatch'], but execution is handled by runContractCheck() in the scanner, not the generic rule loop.
contractSourceis optional onScanConfig. When unset, Phase 4.5 is skipped entirely.- Existing configs without
contractSourcebehave exactly as before contract checking was added. - Spec path resolves relative to
rootDirunless absolute.
These patterns are intentional. Violating them usually indicates a misunderstanding of the design.
packages/core/src/model/result.ts defines a hand-rolled Result<T, E> discriminated union (ok(value) / err(error)).
Use Result for recoverable, expected failures in the production pipeline:
- File read errors (
file-reader.ts) - File discovery failure (
file-scanner.ts) - OpenAPI spec parse errors (
openapi-parser.ts)
Use throw for script/learning contexts where fail-fast is appropriate:
parseSourceFile()throws on read failure. The comment in that file states this explicitly: production scan usesreadFileContent()+Resultand never callsparseSourceFile. Dev scriptscripts/parse-ast.tsusesparseSourceFileand expects throws.
Two separate types in packages/core/src/model/scan-result.ts:
| Type | Meaning | Examples |
|---|---|---|
Finding |
A code issue detected by a rule | Hardcoded URL, missing error handler, contract body mismatch |
ScanDiagnostic |
A tooling/process issue during the scan | Unreadable file, unsupported syntax, rule crashed, unknown rule ID, spec parse failed |
They are never merged into a single list inside core. Both may appear in formatted output (CLI text/json formatters and core reporters render findings and diagnostics in separate sections), but the data model keeps them distinct.
ScanDiagnosticKind values today: parse-error, rule-error, resolve-error, config-warning, unsupported-syntax.
All extraction and body-diff code checks node kinds with TypeScript's ts.isCallExpression, ts.isPropertyAccessExpression, ts.isVariableStatement, etc. before accessing properties. This avoids runtime crashes on unexpected AST shapes. See api-extractor.ts and body-diff.ts.
Core imports only Node builtins and typescript (peer). CLI imports core. Placeholder packages may import core but must not import CLI. Full table in CONTRIBUTING.md.
Core never calls console.*. It uses the injectable Logger interface (packages/core/src/model/logger.ts). The CLI provides makeConsoleLogger() that writes to stderr.
Documented limitations and technical debt — not bugs, but real gaps a contributor should know about.
packages/cli/src/formatters/ (text, json, sarif) implement formatting parallel to packages/core/src/report/ (TerminalReporter, JsonReporter, and helpers in report/format/). The CLI is the production output path today and does not use core reporters. Consolidating these two paths is future cleanup.
Some diagnostic kinds are broader than their names suggest:
parse-erroris used for both unreadable files and caught exceptions duringextractApiCalls()(scanner.tslines ~134 and ~151). Syntax issues have their own kind:unsupported-syntax(fromapi-extractor.ts).resolve-errorsuggests URL/path resolution failure, but today it is only emitted when file discovery fails entirely.url-resolver.tsdoes not emit diagnostics.
Tightening these kinds would be a breaking change to diagnostic consumers and has not been done yet.
Only rules in BUILT_IN_RULES execute. Config entries for unknown rule IDs produce a config-warning and are skipped (rules/index.ts). Loading external rule modules is a deliberate current limitation, not an oversight.
| Document | Audience | Contents |
|---|---|---|
| README.md | End users | Install, config, CLI flags, exit codes |
| CONTRIBUTING.md | Contributors | Dev setup, commit conventions, dependency boundaries, test gaps, PR checklist |
| RELEASING.md | Maintainers | npm publish order and version coupling |