asdlc-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/templates/AGENTS_SECTION.md +57 -0
- package/assets/templates/ARCHITECTURE.md +28 -0
- package/assets/templates/BUSINESS_RULES.md +23 -0
- package/assets/templates/ci-github.yml +36 -0
- package/assets/templates/dependency-cruiser.starter.cjs +23 -0
- package/assets/templates/semgrep-example.yml +16 -0
- package/assets/templates/waivers.yml +18 -0
- package/bin/asdlc.js +2 -0
- package/dist/baseline.js +33 -0
- package/dist/commands/audit.js +30 -0
- package/dist/commands/baseline.js +26 -0
- package/dist/commands/check.js +29 -0
- package/dist/commands/init.js +88 -0
- package/dist/commands/plan.js +38 -0
- package/dist/commands/waiver.js +54 -0
- package/dist/config.js +47 -0
- package/dist/detect.js +61 -0
- package/dist/fingerprint.js +27 -0
- package/dist/gates/boundaries.js +29 -0
- package/dist/gates/contracts.js +29 -0
- package/dist/gates/duplication.js +55 -0
- package/dist/gates/patterns.js +0 -0
- package/dist/gates/registry.js +41 -0
- package/dist/index.js +43 -0
- package/dist/report.js +47 -0
- package/dist/runner.js +50 -0
- package/dist/state.js +29 -0
- package/dist/types.js +1 -0
- package/dist/waivers.js +32 -0
- package/package.json +33 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
[ANTI-DRIFT CONSTITUTION v3.1.1 — managed section]
|
|
2
|
+
|
|
3
|
+
# Standing Orders — read fully before any task
|
|
4
|
+
|
|
5
|
+
Invariant: **within each bounded context and contract version, every governed
|
|
6
|
+
business concept has one approved DEFINITION (docs/BUSINESS_RULES.md);
|
|
7
|
+
implementations declare the version and conformance profile they satisfy and either
|
|
8
|
+
import the canonical module or prove conformance via that profile's contract tests
|
|
9
|
+
(tests/contracts/).** Duplicated meaning is the disease; declared conforming
|
|
10
|
+
implementations for genuinely different contexts are legitimate.
|
|
11
|
+
|
|
12
|
+
Scope of Rules 1/2/4: shared or business-rule-bearing code — governed concepts, the
|
|
13
|
+
shared homes in docs/ARCHITECTURE.md, and anything multiple entry points consume.
|
|
14
|
+
Local glue and trivial private helpers: normal judgment.
|
|
15
|
+
|
|
16
|
+
Rule 1 — Search before create; show the search. For shared/rule-bearing validators,
|
|
17
|
+
components, services, types, constants, utils: search graph+grep+registry first and
|
|
18
|
+
show it. Found → import, or conforming implementation with declared profile +
|
|
19
|
+
contract tests where context requires (say why import wasn't possible). Not found →
|
|
20
|
+
create in its docs/ARCHITECTURE.md home + register, same commit. Unshown search =
|
|
21
|
+
incomplete task.
|
|
22
|
+
|
|
23
|
+
Rule 2 — Undefined business terms, by risk. Blocking tier (money, authz/visibility/
|
|
24
|
+
tenancy, privacy, irreversible, external, data-migration): ask first, always.
|
|
25
|
+
Reversible: propose, proceed, record the assumption visibly. Incidental: judgment.
|
|
26
|
+
Batch questions. Never silently pick a meaning for blocking-tier terms.
|
|
27
|
+
|
|
28
|
+
Rule 3 — Homes and boundaries come from docs/ARCHITECTURE.md. Follow it; propose
|
|
29
|
+
updates rather than deviating. Business logic stays out of routes/screens per that
|
|
30
|
+
document; raw errors go through the shared error mapper.
|
|
31
|
+
|
|
32
|
+
Rule 4 — Changing/removing anything shared = generated sweep. Present the consumer
|
|
33
|
+
list as "all discovered consumers (method, blind spots)" — never claim unqualified
|
|
34
|
+
completeness — update all, or propose a staged migration where same-commit is
|
|
35
|
+
unsafe. Updating only the named file is a bug.
|
|
36
|
+
|
|
37
|
+
Rule 5 — Tests from acceptance criteria / registry definitions, before
|
|
38
|
+
implementing. Never derive tests from the implementation.
|
|
39
|
+
|
|
40
|
+
Rule 6 — Checks before done; never touch them. Run `asdlc check` (functional tests,
|
|
41
|
+
contract-profile tests, registry + waiver-expiry validation, duplication,
|
|
42
|
+
architecture, patterns). Show output; green (legacy: zero NEW) or not done. Never
|
|
43
|
+
raise thresholds, add ignores, edit baselines, or alter CI for green. False
|
|
44
|
+
positive → draft a waiver (.asdlc/waivers.yml, all fields incl. fingerprint) and stop
|
|
45
|
+
for approval. Gate configs, baselines, waivers, rules, this file, ARCHITECTURE.md,
|
|
46
|
+
CI: GOVERN only — human approval with a pointer. A crashed check reports ERROR,
|
|
47
|
+
never PASS.
|
|
48
|
+
|
|
49
|
+
Rule 7 — Leave a trail. New shared module / changed definition / removed feature →
|
|
50
|
+
registry same commit (definitions by hand; implementation/consumer lists change
|
|
51
|
+
only via approved audit proposals).
|
|
52
|
+
|
|
53
|
+
Rule 8 — End-of-task sibling sweep: search once for other occurrences of the
|
|
54
|
+
touched pattern; list, don't fix unprompted.
|
|
55
|
+
|
|
56
|
+
Rule 9 — Scope discipline: work in declared, reviewable chunks; keep coupled
|
|
57
|
+
concepts atomic when separating them would itself create inconsistency.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
<!-- Written from what THIS repo actually does — never prescribed by a tool.
|
|
4
|
+
Fill in the real homes; the boundaries you write here become enforceable
|
|
5
|
+
dependency-cruiser rules. User approves before gates enforce it. -->
|
|
6
|
+
|
|
7
|
+
## Where things live in this repo
|
|
8
|
+
|
|
9
|
+
| Concern | Home | Rule |
|
|
10
|
+
| --- | --- | --- |
|
|
11
|
+
| Business rules / domain logic | `src/lib/` (example) | Screens and routes import from here; never reimplement inline |
|
|
12
|
+
| Shared UI components | `src/components/ui/` (example) | One component per concept; variants via props, not copies |
|
|
13
|
+
| Data access | `src/lib/` or `src/services/` (example) | Routes/screens never query storage directly |
|
|
14
|
+
| Shared types | `src/types/` (example) | One declaration per entity |
|
|
15
|
+
| Constants / config | `src/lib/constants` (example) | No magic values in screens |
|
|
16
|
+
| Error mapping | one shared error mapper | Raw provider/DB errors never reach a client response |
|
|
17
|
+
|
|
18
|
+
## Boundary rules this structure implies
|
|
19
|
+
|
|
20
|
+
<!-- These become .dependency-cruiser.cjs rules. Examples: -->
|
|
21
|
+
- Views/components must not import from `src/app/api/` internals.
|
|
22
|
+
- API routes must not import UI components.
|
|
23
|
+
- Nothing imports from another module's private internals (`**/internal/**`).
|
|
24
|
+
|
|
25
|
+
## Change rule
|
|
26
|
+
|
|
27
|
+
Propose updates to this document rather than deviating from it. Changes are
|
|
28
|
+
GOVERN operations: human approval with a pointer.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Business Rules Registry
|
|
2
|
+
|
|
3
|
+
[Definitions are HUMAN-AUTHORED — the source of truth for meaning.
|
|
4
|
+
Implementation/consumer lists change only via approved audit proposals.]
|
|
5
|
+
|
|
6
|
+
<!-- Copy this section per governed concept. Delete this example once you have real entries. -->
|
|
7
|
+
|
|
8
|
+
## Example: Lead Qualification
|
|
9
|
+
- Id: lead.qualification
|
|
10
|
+
- Bounded context: lead-management
|
|
11
|
+
- Version: 1.0.0
|
|
12
|
+
- Owner: <human/role>
|
|
13
|
+
- Approval ref: <where this definition was approved>
|
|
14
|
+
- Definition: <one unambiguous paragraph — authoritative. This sentence is the
|
|
15
|
+
thing every screen, route, import, and job must agree on. If it isn't written
|
|
16
|
+
here, every AI session will invent its own version.>
|
|
17
|
+
- Risk tier: blocking | reversible | incidental
|
|
18
|
+
- Contract tests: tests/contracts/lead-qualification.test.ts · Profiles: authoritative-server
|
|
19
|
+
- Canonical implementation: <via audit proposals>
|
|
20
|
+
- Conforming implementations: <via audit proposals; each with profile + constraint>
|
|
21
|
+
- Consumers: <via audit proposals; method + confidence + blind spots>
|
|
22
|
+
- Decisions/notes: <assumptions, staged-migration expiries, waiver refs>
|
|
23
|
+
- Last audit sync: <date>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
name: asdlc
|
|
2
|
+
# CI is the real wall — pre-commit hooks are skippable with --no-verify (playbook S1.4).
|
|
3
|
+
# Never modify this workflow to make a failing check pass; that is a GOVERN operation.
|
|
4
|
+
on:
|
|
5
|
+
pull_request:
|
|
6
|
+
push:
|
|
7
|
+
branches: [main]
|
|
8
|
+
schedule:
|
|
9
|
+
- cron: '0 3 1 * *' # monthly audit (playbook S3 Stage 4) — drift is fractal; keep looking
|
|
10
|
+
jobs:
|
|
11
|
+
check:
|
|
12
|
+
if: github.event_name != 'schedule'
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: actions/setup-node@v4
|
|
17
|
+
with:
|
|
18
|
+
node-version: 22
|
|
19
|
+
- run: npm ci
|
|
20
|
+
- run: npx asdlc check
|
|
21
|
+
audit:
|
|
22
|
+
if: github.event_name == 'schedule'
|
|
23
|
+
runs-on: ubuntu-latest
|
|
24
|
+
steps:
|
|
25
|
+
- uses: actions/checkout@v4
|
|
26
|
+
- uses: actions/setup-node@v4
|
|
27
|
+
with:
|
|
28
|
+
node-version: 22
|
|
29
|
+
- run: npm ci
|
|
30
|
+
- run: npx asdlc audit
|
|
31
|
+
- uses: actions/upload-artifact@v4
|
|
32
|
+
with:
|
|
33
|
+
name: asdlc-audit-report
|
|
34
|
+
path: .asdlc/reports/
|
|
35
|
+
# The report is evidence for human review. Applying any of its proposals
|
|
36
|
+
# (baseline accept, waiver renewals) remains a GOVERN operation.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* STARTER boundary rules — only universally-safe checks are active.
|
|
3
|
+
* Real boundary rules must be derived from docs/ARCHITECTURE.md after human
|
|
4
|
+
* review (GOVERN): add `not-reachable-from`/`not-to` rules that encode the
|
|
5
|
+
* homes and boundaries that document approves. Do not let a tool prescribe
|
|
6
|
+
* your architecture.
|
|
7
|
+
*/
|
|
8
|
+
module.exports = {
|
|
9
|
+
forbidden: [
|
|
10
|
+
{
|
|
11
|
+
name: 'no-circular',
|
|
12
|
+
severity: 'error',
|
|
13
|
+
comment: 'Circular dependencies make consumer sweeps and staged migrations unreliable.',
|
|
14
|
+
from: {},
|
|
15
|
+
to: { circular: true },
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
options: {
|
|
19
|
+
doNotFollow: { path: 'node_modules' },
|
|
20
|
+
exclude: { path: '\\.(test|spec)\\.[jt]sx?$' },
|
|
21
|
+
tsPreCompilationDeps: true,
|
|
22
|
+
},
|
|
23
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# GOVERNED RATCHET — see playbook S3.4 rule lifecycle; agents draft, humans activate.
|
|
2
|
+
# New rules on an existing repo always start advisory: draft → impact scan →
|
|
3
|
+
# baseline approved existing matches → then block new matches only.
|
|
4
|
+
#
|
|
5
|
+
# This file ships DISABLED (note the .disabled suffix on install). To activate:
|
|
6
|
+
# 1. Write the rule for a divergence class your audit actually found.
|
|
7
|
+
# 2. Add one must-match and one must-not-match fixture beside it.
|
|
8
|
+
# 3. Run the impact scan (asdlc audit), review matches with a human.
|
|
9
|
+
# 4. Rename to .yml — from then on it gates new matches.
|
|
10
|
+
rules:
|
|
11
|
+
- id: example-raw-error-to-client
|
|
12
|
+
languages: [ts]
|
|
13
|
+
severity: WARNING
|
|
14
|
+
message: Raw provider error returned to client — route it through the shared error mapper.
|
|
15
|
+
patterns:
|
|
16
|
+
- pattern: NextResponse.json({ error: $ERR.message }, ...)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Every suppression lives here — inline ignores without a matching waiver fail the
|
|
2
|
+
# registry gate. Agents DRAFT waivers with all fields; a human approves; the agent
|
|
3
|
+
# records the pointer. Expiry ends suppression: the finding turns red until renewed
|
|
4
|
+
# (GOVERN) or fixed.
|
|
5
|
+
#
|
|
6
|
+
# waivers:
|
|
7
|
+
# - id: W-0001
|
|
8
|
+
# gate: duplication # duplication | boundaries | patterns | registry | contracts
|
|
9
|
+
# scope: src/example/file.ts
|
|
10
|
+
# fingerprint: "<16-hex fingerprint of the exact finding, from asdlc check output>"
|
|
11
|
+
# reason: "<why the policy is wrong here>"
|
|
12
|
+
# approved_by: "<human>"
|
|
13
|
+
# approval_ref: "<message/PR/decision note pointer>"
|
|
14
|
+
# approved_on: 2026-01-01
|
|
15
|
+
# expires_on: 2026-04-01 # permanence requires explicit justification
|
|
16
|
+
# compensating_control: "<what protects meanwhile>"
|
|
17
|
+
# retirement_condition: "<what ends this waiver>"
|
|
18
|
+
waivers: []
|
package/bin/asdlc.js
ADDED
package/dist/baseline.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { ASDLC_DIR } from './state.js';
|
|
4
|
+
export function baselinePath(root) {
|
|
5
|
+
return join(root, ASDLC_DIR, 'baseline.json');
|
|
6
|
+
}
|
|
7
|
+
export function loadBaseline(root) {
|
|
8
|
+
const p = baselinePath(root);
|
|
9
|
+
if (!existsSync(p))
|
|
10
|
+
return new Set();
|
|
11
|
+
const doc = JSON.parse(readFileSync(p, 'utf8'));
|
|
12
|
+
return new Set(doc.accepted ?? []);
|
|
13
|
+
}
|
|
14
|
+
export function writeProposedBaseline(root, findings, file) {
|
|
15
|
+
const doc = { accepted: [...new Set(findings.map((f) => f.fingerprint))].sort() };
|
|
16
|
+
writeFileSync(file, JSON.stringify(doc, null, 2));
|
|
17
|
+
}
|
|
18
|
+
// Pure evaluation: given raw findings, decide the gate verdict.
|
|
19
|
+
// Greenfield: any unwaived finding fails. Legacy: only NEW (unbaselined, unwaived) fails.
|
|
20
|
+
export function evaluateGate(gate, findings, baseline, waived, mode) {
|
|
21
|
+
const unwaived = findings.filter((f) => !waived.has(f.fingerprint));
|
|
22
|
+
const newFindings = mode === 'legacy'
|
|
23
|
+
? unwaived.filter((f) => !baseline.has(f.fingerprint))
|
|
24
|
+
: unwaived;
|
|
25
|
+
const waivedCount = findings.length - unwaived.length;
|
|
26
|
+
const baselinedCount = mode === 'legacy'
|
|
27
|
+
? unwaived.length - newFindings.length
|
|
28
|
+
: 0;
|
|
29
|
+
const status = newFindings.length > 0
|
|
30
|
+
? 'FAIL'
|
|
31
|
+
: findings.length > 0 ? 'PASS_WITH_FINDINGS' : 'PASS';
|
|
32
|
+
return { gate, status, findings, newFindings, baselined: baselinedCount, waived: waivedCount };
|
|
33
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import pc from 'picocolors';
|
|
4
|
+
import { loadConfig } from '../config.js';
|
|
5
|
+
import { runAllGates } from '../runner.js';
|
|
6
|
+
import { renderMarkdownReport, renderResults } from '../report.js';
|
|
7
|
+
import { writeProposedBaseline } from '../baseline.js';
|
|
8
|
+
import { ASDLC_DIR, readState, writeState } from '../state.js';
|
|
9
|
+
// "asdlc audit" — read-only diagnosis (S3 Stage 1 / Stage 4).
|
|
10
|
+
// Writes only reports and a PROPOSED baseline under .asdlc/; applying anything
|
|
11
|
+
// is a separate GOVERN operation with human approval (`asdlc baseline accept`).
|
|
12
|
+
export async function auditCommand(root) {
|
|
13
|
+
const config = loadConfig(root);
|
|
14
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
15
|
+
const results = await runAllGates(root, config);
|
|
16
|
+
const dir = join(root, ASDLC_DIR, 'reports', date);
|
|
17
|
+
mkdirSync(dir, { recursive: true });
|
|
18
|
+
writeFileSync(join(dir, 'audit.md'), renderMarkdownReport(results, date));
|
|
19
|
+
const allFindings = results.flatMap((r) => r.findings);
|
|
20
|
+
const proposedPath = join(dir, 'proposed-baseline.json');
|
|
21
|
+
writeProposedBaseline(root, allFindings, proposedPath);
|
|
22
|
+
const state = readState(root);
|
|
23
|
+
if (state)
|
|
24
|
+
writeState(root, { ...state, last_audit: date });
|
|
25
|
+
console.log(renderResults(results, false));
|
|
26
|
+
console.log('');
|
|
27
|
+
console.log(pc.bold(`Audit written: ${join('.asdlc/reports', date, 'audit.md')}`));
|
|
28
|
+
console.log(`Proposed baseline (${allFindings.length} findings, PROPOSED_NOT_ACCEPTED): ${join('.asdlc/reports', date, 'proposed-baseline.json')}`);
|
|
29
|
+
console.log(pc.dim('Accepting it is a GOVERN operation: asdlc baseline accept --from <file> --approved-by <human> --approval-ref <pointer>'));
|
|
30
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { copyFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import pc from 'picocolors';
|
|
3
|
+
import { baselinePath } from '../baseline.js';
|
|
4
|
+
import { readState, writeState } from '../state.js';
|
|
5
|
+
// GOVERN: accepting a baseline requires a human approval pointer. An agent
|
|
6
|
+
// cannot approve its own governance change (playbook S0) — the CLI enforces
|
|
7
|
+
// the record-keeping; the human enforces the truth of it.
|
|
8
|
+
export function baselineAcceptCommand(root, opts) {
|
|
9
|
+
if (!opts.from || !existsSync(opts.from)) {
|
|
10
|
+
console.error(pc.red('Provide --from <proposed-baseline.json> produced by `asdlc audit`.'));
|
|
11
|
+
process.exitCode = 1;
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
if (!opts.approvedBy || !opts.approvalRef) {
|
|
15
|
+
console.error(pc.red('Baseline acceptance is a GOVERN operation: --approved-by <human> and --approval-ref <pointer to where approval was given> are required.'));
|
|
16
|
+
process.exitCode = 1;
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
copyFileSync(opts.from, baselinePath(root));
|
|
20
|
+
const state = readState(root);
|
|
21
|
+
if (state) {
|
|
22
|
+
writeState(root, { ...state, baseline_revision: state.baseline_revision + 1 });
|
|
23
|
+
}
|
|
24
|
+
console.log(pc.green(`Baseline accepted (rev ${state ? state.baseline_revision + 1 : '?'}) — approved by ${opts.approvedBy}, ref: ${opts.approvalRef}`));
|
|
25
|
+
console.log('From here, only NEW findings gate. Burn the baseline down via audits.');
|
|
26
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
import { readState } from '../state.js';
|
|
3
|
+
import { loadConfig } from '../config.js';
|
|
4
|
+
import { runAllGates, overallExitCode } from '../runner.js';
|
|
5
|
+
import { renderResults } from '../report.js';
|
|
6
|
+
// "asdlc check" — the gate wall. Green (legacy: zero NEW) or not done (S2.6).
|
|
7
|
+
export async function checkCommand(root, opts) {
|
|
8
|
+
const state = readState(root);
|
|
9
|
+
if (!state) {
|
|
10
|
+
console.error(pc.red('No ASDLC install found (.asdlc/state.yml missing). Run `asdlc plan`, then `asdlc init`.'));
|
|
11
|
+
process.exitCode = 1;
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
if (state.status === 'SUSPENDED') {
|
|
15
|
+
console.error(pc.yellow('Install is SUSPENDED; checks report but do not gate.'));
|
|
16
|
+
}
|
|
17
|
+
const config = loadConfig(root);
|
|
18
|
+
const results = await runAllGates(root, config);
|
|
19
|
+
console.log(renderResults(results, opts.verbose ?? false));
|
|
20
|
+
const code = overallExitCode(results);
|
|
21
|
+
console.log('');
|
|
22
|
+
if (code === 0) {
|
|
23
|
+
console.log(pc.green(`asdlc check: green (mode ${config.mode} — ${config.mode === 'legacy' ? 'zero NEW findings' : 'zero findings'})`));
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
console.log(pc.red('asdlc check: RED. Never weaken a check to pass it — fix the finding, or draft a waiver in .asdlc/waivers.yml and stop for human approval.'));
|
|
27
|
+
}
|
|
28
|
+
process.exitCode = state.status === 'SUSPENDED' ? 0 : code;
|
|
29
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync, existsSync, readFileSync, appendFileSync } from 'node:fs';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import pc from 'picocolors';
|
|
5
|
+
import { detectStack } from '../detect.js';
|
|
6
|
+
import { ASDLC_DIR, writeState } from '../state.js';
|
|
7
|
+
import { defaultConfig } from '../config.js';
|
|
8
|
+
import { stringify } from 'yaml';
|
|
9
|
+
const ASSETS = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'assets');
|
|
10
|
+
function writeIfAbsent(path, content) {
|
|
11
|
+
if (existsSync(path))
|
|
12
|
+
return false;
|
|
13
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
14
|
+
writeFileSync(path, content);
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
const MARKER = '[ANTI-DRIFT CONSTITUTION v3.1.1 — managed section]';
|
|
18
|
+
// "asdlc init" — scaffolds governance. Existing files are never overwritten;
|
|
19
|
+
// AGENTS.md gets the managed section appended under a marker (playbook S1.2).
|
|
20
|
+
export function initCommand(root, opts) {
|
|
21
|
+
const s = detectStack(root);
|
|
22
|
+
if (!s.gitRepo) {
|
|
23
|
+
console.error(pc.red('Refusing to install: not a git repository. Run `git init` first.'));
|
|
24
|
+
process.exitCode = 1;
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const mode = opts.mode ?? (s.fileCount > 60 ? 'legacy' : 'greenfield');
|
|
28
|
+
const created = [];
|
|
29
|
+
const skipped = [];
|
|
30
|
+
const track = (path, wrote) => (wrote ? created : skipped).push(path);
|
|
31
|
+
mkdirSync(join(root, ASDLC_DIR, 'semgrep'), { recursive: true });
|
|
32
|
+
mkdirSync(join(root, ASDLC_DIR, 'reports'), { recursive: true });
|
|
33
|
+
const config = { ...defaultConfig(), mode, source_dirs: s.sourceDirs.length > 0 ? s.sourceDirs : ['src'] };
|
|
34
|
+
track('.asdlc/config.yml', writeIfAbsent(join(root, ASDLC_DIR, 'config.yml'), stringify(config)));
|
|
35
|
+
track('.asdlc/waivers.yml', writeIfAbsent(join(root, ASDLC_DIR, 'waivers.yml'), readFileSync(join(ASSETS, 'templates', 'waivers.yml'), 'utf8')));
|
|
36
|
+
track('.asdlc/baseline.json', writeIfAbsent(join(root, ASDLC_DIR, 'baseline.json'), JSON.stringify({ accepted: [] }, null, 2)));
|
|
37
|
+
for (const [asset, dest] of [
|
|
38
|
+
['BUSINESS_RULES.md', 'docs/BUSINESS_RULES.md'],
|
|
39
|
+
['ARCHITECTURE.md', 'docs/ARCHITECTURE.md'],
|
|
40
|
+
['semgrep-example.yml', '.asdlc/semgrep/example.yml.disabled'],
|
|
41
|
+
['dependency-cruiser.starter.cjs', '.dependency-cruiser.cjs'],
|
|
42
|
+
]) {
|
|
43
|
+
track(dest, writeIfAbsent(join(root, dest), readFileSync(join(ASSETS, 'templates', asset), 'utf8')));
|
|
44
|
+
}
|
|
45
|
+
// AGENTS.md: create, or merge by appending under the marker — keep all prior instructions.
|
|
46
|
+
const agentsPath = join(root, 'AGENTS.md');
|
|
47
|
+
const section = readFileSync(join(ASSETS, 'templates', 'AGENTS_SECTION.md'), 'utf8');
|
|
48
|
+
if (!existsSync(agentsPath)) {
|
|
49
|
+
writeFileSync(agentsPath, section);
|
|
50
|
+
created.push('AGENTS.md');
|
|
51
|
+
}
|
|
52
|
+
else if (!readFileSync(agentsPath, 'utf8').includes(MARKER)) {
|
|
53
|
+
appendFileSync(agentsPath, `\n\n${section}`);
|
|
54
|
+
created.push('AGENTS.md (managed section appended — review the diff)');
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
skipped.push('AGENTS.md (managed section already present)');
|
|
58
|
+
}
|
|
59
|
+
// CI is the real wall — hooks are skippable (S1.4).
|
|
60
|
+
let ciWired = false;
|
|
61
|
+
if (s.ci.includes('GitHub Actions') || existsSync(join(root, '.github'))) {
|
|
62
|
+
ciWired = writeIfAbsent(join(root, '.github', 'workflows', 'asdlc.yml'), readFileSync(join(ASSETS, 'templates', 'ci-github.yml'), 'utf8'));
|
|
63
|
+
track('.github/workflows/asdlc.yml', ciWired);
|
|
64
|
+
if (!ciWired)
|
|
65
|
+
ciWired = true; // workflow already exists
|
|
66
|
+
}
|
|
67
|
+
// No CI = setup incomplete → status PARTIAL, reported in bold (S1.4).
|
|
68
|
+
const status = ciWired ? 'ACTIVE' : 'PARTIAL';
|
|
69
|
+
writeState(root, {
|
|
70
|
+
schema_version: 1, playbook_version: '3.1.1', status, mode,
|
|
71
|
+
config_revision: 1, baseline_revision: 1, last_audit: null,
|
|
72
|
+
});
|
|
73
|
+
created.push('.asdlc/state.yml');
|
|
74
|
+
console.log([
|
|
75
|
+
pc.bold(`ASDLC installed — status ${status === 'ACTIVE' ? pc.green(status) : pc.bold(pc.yellow(status))}, mode ${mode.toUpperCase()}`),
|
|
76
|
+
'',
|
|
77
|
+
'Created:', ...created.map((f) => ` + ${f}`),
|
|
78
|
+
...(skipped.length > 0 ? ['Preserved (already existed):', ...skipped.map((f) => ` = ${f}`)] : []),
|
|
79
|
+
'',
|
|
80
|
+
status === 'PARTIAL'
|
|
81
|
+
? pc.bold(pc.yellow('NO CI DETECTED — pre-commit hooks are skippable; setup is incomplete until a CI runs `asdlc check`.'))
|
|
82
|
+
: 'CI workflow in place. Merge nothing past a red check.',
|
|
83
|
+
'',
|
|
84
|
+
mode === 'legacy'
|
|
85
|
+
? 'Legacy mode: run `asdlc audit` next to capture the proposed baseline, review it, then accept with `asdlc baseline accept`.'
|
|
86
|
+
: 'Greenfield: gates block from the first commit. Run `asdlc check`.',
|
|
87
|
+
].join('\n'));
|
|
88
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
import { detectStack } from '../detect.js';
|
|
3
|
+
import { readState } from '../state.js';
|
|
4
|
+
import { semgrepAvailable } from '../gates/patterns.js';
|
|
5
|
+
// "asdlc plan" — read-only, always first (playbook S1.0). Mutates nothing.
|
|
6
|
+
export function planCommand(root) {
|
|
7
|
+
const s = detectStack(root);
|
|
8
|
+
const state = readState(root);
|
|
9
|
+
const mode = s.fileCount > 60 ? 'legacy' : 'greenfield';
|
|
10
|
+
const lines = [
|
|
11
|
+
pc.bold('ASDLC plan (read-only — nothing has been changed)'),
|
|
12
|
+
'',
|
|
13
|
+
`Languages: ${s.languages.join(', ') || 'none detected'}`,
|
|
14
|
+
`Package manager: ${s.packageManager ?? 'none'}`,
|
|
15
|
+
`Test runner: ${s.testRunner ?? 'none detected'}`,
|
|
16
|
+
`CI: ${s.ci.join(', ') || pc.red('NONE — no CI means setup will be PARTIAL, not ACTIVE')}`,
|
|
17
|
+
`Agent files: ${s.agentFiles.join(', ') || 'none'}`,
|
|
18
|
+
`Source dirs: ${s.sourceDirs.join(', ') || 'none found'}`,
|
|
19
|
+
`Repo size: ~${s.fileCount} files`,
|
|
20
|
+
`Git repository: ${s.gitRepo ? 'yes' : pc.red('NO — install requires version control')}`,
|
|
21
|
+
s.uncommitted !== null && s.uncommitted > 0
|
|
22
|
+
? pc.yellow(`Uncommitted: ${s.uncommitted} changes — these will be preserved, commit them first if possible`)
|
|
23
|
+
: `Uncommitted: ${s.uncommitted ?? 'n/a'}`,
|
|
24
|
+
`Existing install: ${state ? `${state.status} (playbook ${state.playbook_version})` : 'none'}`,
|
|
25
|
+
'',
|
|
26
|
+
pc.bold(`Proposed mode: ${mode.toUpperCase()}`),
|
|
27
|
+
mode === 'legacy'
|
|
28
|
+
? ' All gates start baselined/advisory and ratchet to block-new-only (S1.3).'
|
|
29
|
+
: ' Gates block from day one.',
|
|
30
|
+
'',
|
|
31
|
+
'Proposed tools (new dev dependencies on install): jscpd, dependency-cruiser' +
|
|
32
|
+
(semgrepAvailable() ? '; semgrep found on PATH' : pc.dim('; semgrep NOT found — patterns gate will report ERROR until installed or disabled')),
|
|
33
|
+
'',
|
|
34
|
+
pc.bold('Next step: review the above, then run `asdlc init` to install.'),
|
|
35
|
+
'This tool grants itself no authority: init only proceeds because you, the human, run it.',
|
|
36
|
+
];
|
|
37
|
+
console.log(lines.join('\n'));
|
|
38
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import pc from 'picocolors';
|
|
4
|
+
import { ASDLC_DIR } from '../state.js';
|
|
5
|
+
const GATES = ['duplication', 'boundaries', 'patterns', 'registry', 'contracts'];
|
|
6
|
+
// "asdlc waiver draft" — the agent-side half of the waiver protocol: drafts an
|
|
7
|
+
// entry with every field the agent can honestly fill, and leaves approved_by /
|
|
8
|
+
// approval_ref EMPTY. An incomplete waiver is malformed, so the registry gate
|
|
9
|
+
// flags it red until a human fills in the approval — "draft and stop for
|
|
10
|
+
// approval" enforced by construction, not by convention.
|
|
11
|
+
export function waiverDraftCommand(root, opts) {
|
|
12
|
+
if (!opts.fingerprint || !opts.gate || !opts.scope || !opts.reason) {
|
|
13
|
+
console.error(pc.red('Required: --fingerprint <16-hex from check output> --gate <gate> --scope <path> --reason "<why the policy is wrong here>"'));
|
|
14
|
+
process.exitCode = 1;
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (!GATES.includes(opts.gate)) {
|
|
18
|
+
console.error(pc.red(`--gate must be one of: ${GATES.join(', ')}`));
|
|
19
|
+
process.exitCode = 1;
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const path = join(root, ASDLC_DIR, 'waivers.yml');
|
|
23
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
24
|
+
const existing = existsSync(path) ? readFileSync(path, 'utf8') : 'waivers: []\n';
|
|
25
|
+
const ids = [...existing.matchAll(/id:\s*W-(\d+)/g)].map((m) => Number(m[1]));
|
|
26
|
+
const nextId = `W-${String((ids.length > 0 ? Math.max(...ids) : 0) + 1).padStart(4, '0')}`;
|
|
27
|
+
const days = Number(opts.expiresInDays ?? 90);
|
|
28
|
+
const expires = new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 10);
|
|
29
|
+
const block = [
|
|
30
|
+
` - id: ${nextId}`,
|
|
31
|
+
` gate: ${opts.gate}`,
|
|
32
|
+
` scope: ${opts.scope}`,
|
|
33
|
+
` fingerprint: "${opts.fingerprint}"`,
|
|
34
|
+
` reason: "${opts.reason.replace(/"/g, '\\"')}"`,
|
|
35
|
+
` approved_by: "" # HUMAN: fill in`,
|
|
36
|
+
` approval_ref: "" # HUMAN: pointer to where approval was given`,
|
|
37
|
+
` approved_on: "" # HUMAN: YYYY-MM-DD`,
|
|
38
|
+
` expires_on: ${expires}`,
|
|
39
|
+
` compensating_control: ""`,
|
|
40
|
+
` retirement_condition: ""`,
|
|
41
|
+
'',
|
|
42
|
+
].join('\n');
|
|
43
|
+
// String-level append preserves the file's instructional comments.
|
|
44
|
+
const updated = /waivers:\s*\[\]/.test(existing)
|
|
45
|
+
? existing.replace(/waivers:\s*\[\]/, `waivers:\n${block}`)
|
|
46
|
+
: `${existing.trimEnd()}\n${block}`;
|
|
47
|
+
writeFileSync(path, updated);
|
|
48
|
+
console.log([
|
|
49
|
+
pc.green(`Draft waiver ${nextId} written to .asdlc/waivers.yml (expires ${expires}).`),
|
|
50
|
+
pc.bold(pc.yellow('It is NOT active: approved_by / approval_ref / approved_on are empty,')),
|
|
51
|
+
pc.yellow('so the registry gate will flag it as malformed until a human fills them in.'),
|
|
52
|
+
'This is the protocol: agents draft, humans approve, the approval leaves a pointer.',
|
|
53
|
+
].join('\n'));
|
|
54
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { parse } from 'yaml';
|
|
4
|
+
import { ASDLC_DIR } from './state.js';
|
|
5
|
+
// Playbook S1.3 default excludes: node_modules, dist/build, tests, migrations,
|
|
6
|
+
// generated, vendor, lockfiles. Tests are excluded from clone detection —
|
|
7
|
+
// test boilerplate similarity is not business-rule drift.
|
|
8
|
+
export const DEFAULT_EXCLUDES = [
|
|
9
|
+
'**/node_modules/**', '**/dist/**', '**/build/**', '**/.next/**',
|
|
10
|
+
'**/coverage/**', '**/vendor/**', '**/*.min.*', '**/generated/**',
|
|
11
|
+
'**/migrations/**', '**/*.lock', '**/package-lock.json', '**/.asdlc/**',
|
|
12
|
+
'**/*.test.*', '**/*.spec.*', '**/__tests__/**', '**/tests/**', '**/test/**',
|
|
13
|
+
'**/*.stories.*', '**/__mocks__/**', '**/fixtures/**',
|
|
14
|
+
];
|
|
15
|
+
export function defaultConfig() {
|
|
16
|
+
return {
|
|
17
|
+
mode: 'legacy',
|
|
18
|
+
gates: {
|
|
19
|
+
duplication: { enabled: true },
|
|
20
|
+
boundaries: { enabled: true },
|
|
21
|
+
patterns: { enabled: true },
|
|
22
|
+
registry: { enabled: true },
|
|
23
|
+
contracts: { enabled: false },
|
|
24
|
+
},
|
|
25
|
+
source_dirs: ['src'],
|
|
26
|
+
min_tokens: 60,
|
|
27
|
+
exclude: DEFAULT_EXCLUDES,
|
|
28
|
+
registry_path: 'docs/BUSINESS_RULES.md',
|
|
29
|
+
architecture_path: 'docs/ARCHITECTURE.md',
|
|
30
|
+
contracts_command: null,
|
|
31
|
+
semgrep_rules_dir: '.asdlc/semgrep',
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export function loadConfig(root) {
|
|
35
|
+
const p = join(root, ASDLC_DIR, 'config.yml');
|
|
36
|
+
const base = defaultConfig();
|
|
37
|
+
if (!existsSync(p))
|
|
38
|
+
return base;
|
|
39
|
+
const raw = (parse(readFileSync(p, 'utf8')) ?? {});
|
|
40
|
+
return {
|
|
41
|
+
...base,
|
|
42
|
+
...raw,
|
|
43
|
+
gates: { ...base.gates, ...(raw.gates ?? {}) },
|
|
44
|
+
exclude: raw.exclude ?? base.exclude,
|
|
45
|
+
source_dirs: raw.source_dirs ?? base.source_dirs,
|
|
46
|
+
};
|
|
47
|
+
}
|
package/dist/detect.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { readFileSync, existsSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
export function detectStack(root) {
|
|
5
|
+
const has = (p) => existsSync(join(root, p));
|
|
6
|
+
const pkg = has('package.json')
|
|
7
|
+
? JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) : null;
|
|
8
|
+
const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) };
|
|
9
|
+
const languages = [];
|
|
10
|
+
if (pkg)
|
|
11
|
+
languages.push(has('tsconfig.json') ? 'TypeScript' : 'JavaScript');
|
|
12
|
+
if (has('pyproject.toml') || has('requirements.txt'))
|
|
13
|
+
languages.push('Python');
|
|
14
|
+
if (has('go.mod'))
|
|
15
|
+
languages.push('Go');
|
|
16
|
+
if (has('Cargo.toml'))
|
|
17
|
+
languages.push('Rust');
|
|
18
|
+
const packageManager = has('pnpm-lock.yaml') ? 'pnpm' :
|
|
19
|
+
has('yarn.lock') ? 'yarn' :
|
|
20
|
+
has('package-lock.json') ? 'npm' :
|
|
21
|
+
has('bun.lockb') || has('bun.lock') ? 'bun' : null;
|
|
22
|
+
const testRunner = deps['vitest'] ? 'vitest' :
|
|
23
|
+
deps['jest'] ? 'jest' :
|
|
24
|
+
deps['mocha'] ? 'mocha' :
|
|
25
|
+
has('pytest.ini') || has('pyproject.toml') ? null : null;
|
|
26
|
+
const ci = [];
|
|
27
|
+
if (has('.github/workflows'))
|
|
28
|
+
ci.push('GitHub Actions');
|
|
29
|
+
if (has('.gitlab-ci.yml'))
|
|
30
|
+
ci.push('GitLab CI');
|
|
31
|
+
if (has('.circleci'))
|
|
32
|
+
ci.push('CircleCI');
|
|
33
|
+
const agentFiles = ['AGENTS.md', 'CLAUDE.md', '.cursorrules', 'CODEOWNERS', '.github/CODEOWNERS']
|
|
34
|
+
.filter(has);
|
|
35
|
+
const sourceDirs = ['src', 'app', 'lib', 'packages'].filter((d) => has(d));
|
|
36
|
+
let fileCount = 0;
|
|
37
|
+
const count = (dir, depth) => {
|
|
38
|
+
if (depth > 6)
|
|
39
|
+
return;
|
|
40
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
41
|
+
if (e.name === 'node_modules' || e.name.startsWith('.'))
|
|
42
|
+
continue;
|
|
43
|
+
if (e.isDirectory())
|
|
44
|
+
count(join(dir, e.name), depth + 1);
|
|
45
|
+
else
|
|
46
|
+
fileCount += 1;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
try {
|
|
50
|
+
count(root, 0);
|
|
51
|
+
}
|
|
52
|
+
catch { /* unreadable areas don't crash detection */ }
|
|
53
|
+
const gitRepo = has('.git');
|
|
54
|
+
let uncommitted = null;
|
|
55
|
+
if (gitRepo) {
|
|
56
|
+
const res = spawnSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8' });
|
|
57
|
+
if (res.status === 0)
|
|
58
|
+
uncommitted = res.stdout.split('\n').filter(Boolean).length;
|
|
59
|
+
}
|
|
60
|
+
return { languages, packageManager, testRunner, ci, agentFiles, sourceDirs, fileCount, uncommitted, gitRepo };
|
|
61
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
// Fingerprints are gate:rule:path:symbol — deliberately line-independent so
|
|
3
|
+
// edits elsewhere in a file don't churn baselines or waivers (playbook S1.3).
|
|
4
|
+
export function fingerprint(gate, rule, path, symbol) {
|
|
5
|
+
const key = [gate, rule, normalizePath(path), symbol].join(':');
|
|
6
|
+
return createHash('sha256').update(key).digest('hex').slice(0, 16);
|
|
7
|
+
}
|
|
8
|
+
export function normalizePath(p) {
|
|
9
|
+
return p.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
10
|
+
}
|
|
11
|
+
// Whitespace-normalized content hash: stable when code moves, changes when the
|
|
12
|
+
// code itself changes — used to keep distinct matches from sharing a fingerprint.
|
|
13
|
+
export function contentHash(text) {
|
|
14
|
+
return createHash('sha256').update(text.replace(/\s+/g, ' ').trim()).digest('hex').slice(0, 8);
|
|
15
|
+
}
|
|
16
|
+
// Deterministic disambiguation for otherwise-identical findings: first
|
|
17
|
+
// occurrence keeps the base symbol, repeats get #1, #2, … so a NEW identical
|
|
18
|
+
// match can never hide behind a baselined or waived one.
|
|
19
|
+
export function occurrence(counts, base) {
|
|
20
|
+
const n = counts.get(base) ?? 0;
|
|
21
|
+
counts.set(base, n + 1);
|
|
22
|
+
return n === 0 ? base : `${base}#${n}`;
|
|
23
|
+
}
|
|
24
|
+
export function makeFinding(gate, rule, path, symbol, message) {
|
|
25
|
+
return { gate, rule, path: normalizePath(path), symbol, message,
|
|
26
|
+
fingerprint: fingerprint(gate, rule, path, symbol) };
|
|
27
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { makeFinding } from '../fingerprint.js';
|
|
4
|
+
// G2 — architecture boundaries via dependency-cruiser's programmatic API.
|
|
5
|
+
// Rules come from the repo's own .dependency-cruiser.cjs (written by `asdlc init`
|
|
6
|
+
// from ARCHITECTURE.md review — never invented by the tool). No rules file means
|
|
7
|
+
// the gate cannot express an opinion: the caller reports SKIPPED, not PASS.
|
|
8
|
+
export async function runBoundariesGate(root, config) {
|
|
9
|
+
const ruleFile = ['.dependency-cruiser.cjs', '.dependency-cruiser.js', '.dependency-cruiser.json']
|
|
10
|
+
.map((f) => join(root, f))
|
|
11
|
+
.find((f) => existsSync(f));
|
|
12
|
+
if (!ruleFile)
|
|
13
|
+
return 'UNCONFIGURED';
|
|
14
|
+
const { cruise } = await import('dependency-cruiser');
|
|
15
|
+
const extract = await import('dependency-cruiser/config-utl/extract-depcruise-config');
|
|
16
|
+
const ruleSet = await (extract.default ?? extract)(ruleFile);
|
|
17
|
+
const dirs = config.source_dirs.filter((d) => existsSync(join(root, d)));
|
|
18
|
+
const prevCwd = process.cwd();
|
|
19
|
+
process.chdir(root);
|
|
20
|
+
try {
|
|
21
|
+
const result = await cruise(dirs, { ruleSet, validate: true, outputType: 'json' });
|
|
22
|
+
const output = typeof result.output === 'string' ? JSON.parse(result.output) : result.output;
|
|
23
|
+
const violations = (output.summary?.violations ?? []);
|
|
24
|
+
return violations.map((v) => makeFinding('boundaries', v.rule.name, v.from, v.to, `${v.rule.name}: ${v.from} → ${v.to}`));
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
process.chdir(prevCwd);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { makeFinding } from '../fingerprint.js';
|
|
3
|
+
// Tokenize a command string without a shell: whitespace-split with double/single
|
|
4
|
+
// quote grouping. No metacharacter interpretation — config cannot smuggle
|
|
5
|
+
// `;`/`&&`/backticks into a shell (defense-in-depth; config is GOVERN-owned).
|
|
6
|
+
export function tokenizeCommand(command) {
|
|
7
|
+
const tokens = command.match(/"[^"]*"|'[^']*'|\S+/g) ?? [];
|
|
8
|
+
return tokens.map((t) => t.replace(/^(["'])(.*)\1$/, '$2'));
|
|
9
|
+
}
|
|
10
|
+
// G5 — contract-profile suites. ASDLC does not own test semantics; it runs the
|
|
11
|
+
// repo's configured contract command (e.g. "npm test -- tests/contracts") and
|
|
12
|
+
// translates a failure into a finding. Unconfigured → SKIPPED, never PASS.
|
|
13
|
+
export function runContractsGate(root, config) {
|
|
14
|
+
if (!config.contracts_command)
|
|
15
|
+
return 'UNCONFIGURED';
|
|
16
|
+
const [cmd, ...args] = tokenizeCommand(config.contracts_command);
|
|
17
|
+
if (!cmd)
|
|
18
|
+
return 'UNCONFIGURED';
|
|
19
|
+
const res = spawnSync(cmd, args, {
|
|
20
|
+
cwd: root, shell: false, encoding: 'utf8', timeout: 900_000,
|
|
21
|
+
});
|
|
22
|
+
if (res.error || res.status === null) {
|
|
23
|
+
throw new Error(`contract suite could not run: ${res.error?.message ?? res.stderr?.slice(0, 400)}`);
|
|
24
|
+
}
|
|
25
|
+
if (res.status !== 0) {
|
|
26
|
+
return [makeFinding('contracts', 'contract-suite-failed', 'tests/contracts', '-', `Contract command exited ${res.status}. Tail: ${(res.stdout + res.stderr).slice(-500)}`)];
|
|
27
|
+
}
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { readFileSync, mkdtempSync, existsSync, rmSync } from 'node:fs';
|
|
3
|
+
import { join, dirname, basename } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
import { makeFinding, contentHash, occurrence } from '../fingerprint.js';
|
|
7
|
+
// jscpd's exports map blocks package.json resolution and its ESM entry is
|
|
8
|
+
// broken on Node >= 22, so locate the package root by walking up from the
|
|
9
|
+
// resolved entry (path-separator agnostic) and drive the stable CLI binary.
|
|
10
|
+
function resolveJscpdBin() {
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
let dir = dirname(require.resolve('jscpd'));
|
|
13
|
+
while (basename(dir) !== 'jscpd' && dirname(dir) !== dir)
|
|
14
|
+
dir = dirname(dir);
|
|
15
|
+
const bin = join(dir, 'bin', 'jscpd');
|
|
16
|
+
if (basename(dir) !== 'jscpd' || !existsSync(bin)) {
|
|
17
|
+
throw new Error(`jscpd binary not found (searched from ${dir})`);
|
|
18
|
+
}
|
|
19
|
+
return bin;
|
|
20
|
+
}
|
|
21
|
+
// G1 — clone detection via jscpd (open-source, wrapped not rewritten).
|
|
22
|
+
// Returns findings or throws; the caller maps a throw to ERROR, never PASS.
|
|
23
|
+
export function runDuplicationGate(root, config) {
|
|
24
|
+
const bin = resolveJscpdBin();
|
|
25
|
+
const out = mkdtempSync(join(tmpdir(), 'asdlc-jscpd-'));
|
|
26
|
+
try {
|
|
27
|
+
const args = [
|
|
28
|
+
bin,
|
|
29
|
+
...config.source_dirs.filter((d) => existsSync(join(root, d))),
|
|
30
|
+
'--reporters', 'json',
|
|
31
|
+
'--output', out,
|
|
32
|
+
'--min-tokens', String(config.min_tokens),
|
|
33
|
+
'--silent',
|
|
34
|
+
'--ignore', config.exclude.join(','),
|
|
35
|
+
];
|
|
36
|
+
const res = spawnSync(process.execPath, args, { cwd: root, encoding: 'utf8', timeout: 300_000 });
|
|
37
|
+
const reportPath = join(out, 'jscpd-report.json');
|
|
38
|
+
if (!existsSync(reportPath)) {
|
|
39
|
+
throw new Error(`jscpd produced no report (exit ${res.status}): ${res.stderr?.slice(0, 400)}`);
|
|
40
|
+
}
|
|
41
|
+
const report = JSON.parse(readFileSync(reportPath, 'utf8'));
|
|
42
|
+
// Distinct clone groups between the same file pair must not share a
|
|
43
|
+
// fingerprint (a new clone would pass as baselined): identity includes a
|
|
44
|
+
// hash of the cloned fragment, with deterministic #n suffixes as backstop.
|
|
45
|
+
const counts = new Map();
|
|
46
|
+
return (report.duplicates ?? []).map((d) => {
|
|
47
|
+
const [a, b] = [d.firstFile.name, d.secondFile.name].sort();
|
|
48
|
+
const frag = contentHash(d.fragment ?? `${d.lines}:${d.tokens}`);
|
|
49
|
+
return makeFinding('duplication', 'clone', a, occurrence(counts, `${b}@${frag}`), `${d.lines}-line clone shared with ${b}`);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
rmSync(out, { recursive: true, force: true });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { makeFinding } from '../fingerprint.js';
|
|
4
|
+
import { loadWaivers } from '../waivers.js';
|
|
5
|
+
// G4 — registry + waiver validation.
|
|
6
|
+
// The registry (docs/BUSINESS_RULES.md) is human-authored markdown; this gate
|
|
7
|
+
// checks structure and referential integrity, never meaning:
|
|
8
|
+
// - registry file exists and has at least one concept section
|
|
9
|
+
// - every `backtick/path` reference under a concept resolves in the repo
|
|
10
|
+
// - waivers are well-formed; expired waivers surface as findings (suppression ended)
|
|
11
|
+
export function runRegistryGate(root, config) {
|
|
12
|
+
const findings = [];
|
|
13
|
+
const regPath = join(root, config.registry_path);
|
|
14
|
+
if (!existsSync(regPath)) {
|
|
15
|
+
findings.push(makeFinding('registry', 'registry-missing', config.registry_path, '-', `Registry not found at ${config.registry_path}`));
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
const text = readFileSync(regPath, 'utf8');
|
|
19
|
+
const concepts = text.match(/^##\s+.+$/gm) ?? [];
|
|
20
|
+
if (concepts.length === 0) {
|
|
21
|
+
findings.push(makeFinding('registry', 'registry-empty', config.registry_path, '-', 'Registry has no concept sections (## <Concept>)'));
|
|
22
|
+
}
|
|
23
|
+
const pathRefs = [...text.matchAll(/`((?:src|tests|docs|supabase|packages|lib|app)\/[^`\s]+)`/g)]
|
|
24
|
+
.map((m) => m[1]);
|
|
25
|
+
for (const ref of new Set(pathRefs)) {
|
|
26
|
+
const bare = ref.split(':')[0];
|
|
27
|
+
if (!existsSync(join(root, bare))) {
|
|
28
|
+
findings.push(makeFinding('registry', 'dangling-path', config.registry_path, bare, `Registry references ${bare}, which does not exist`));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const waivers = loadWaivers(root);
|
|
33
|
+
for (const m of waivers.malformed) {
|
|
34
|
+
const id = m.entry?.id ?? 'unknown';
|
|
35
|
+
findings.push(makeFinding('registry', 'waiver-malformed', '.asdlc/waivers.yml', id, `Waiver ${id} missing required fields: ${m.missing.join(', ')}`));
|
|
36
|
+
}
|
|
37
|
+
for (const w of waivers.expired) {
|
|
38
|
+
findings.push(makeFinding('registry', 'waiver-expired', '.asdlc/waivers.yml', w.id, `Waiver ${w.id} expired ${w.expires_on}; its finding is no longer suppressed`));
|
|
39
|
+
}
|
|
40
|
+
return findings;
|
|
41
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { planCommand } from './commands/plan.js';
|
|
3
|
+
import { initCommand } from './commands/init.js';
|
|
4
|
+
import { checkCommand } from './commands/check.js';
|
|
5
|
+
import { auditCommand } from './commands/audit.js';
|
|
6
|
+
import { baselineAcceptCommand } from './commands/baseline.js';
|
|
7
|
+
import { waiverDraftCommand } from './commands/waiver.js';
|
|
8
|
+
const program = new Command();
|
|
9
|
+
program
|
|
10
|
+
.name('asdlc')
|
|
11
|
+
.description('ASDLC — anti-drift governance for AI-built codebases (Anti-Drift Playbook v3.1.1)')
|
|
12
|
+
.version('0.1.0');
|
|
13
|
+
program.command('plan')
|
|
14
|
+
.description('Read-only: detect the stack and propose an install plan. Changes nothing.')
|
|
15
|
+
.action(() => planCommand(process.cwd()));
|
|
16
|
+
program.command('init')
|
|
17
|
+
.description('Install governance: .asdlc/, registry templates, AGENTS.md section, CI workflow.')
|
|
18
|
+
.option('--mode <mode>', 'greenfield | legacy (default: auto-detect)')
|
|
19
|
+
.action((opts) => initCommand(process.cwd(), { mode: opts.mode }));
|
|
20
|
+
program.command('check')
|
|
21
|
+
.description('Run all gates. Exit 0 only when green (legacy: zero NEW findings). ERROR never passes.')
|
|
22
|
+
.option('-v, --verbose', 'show all new findings')
|
|
23
|
+
.action((opts) => checkCommand(process.cwd(), opts));
|
|
24
|
+
program.command('audit')
|
|
25
|
+
.description('Read-only diagnosis: full gate sweep, markdown report, PROPOSED baseline.')
|
|
26
|
+
.action(() => auditCommand(process.cwd()));
|
|
27
|
+
const baseline = program.command('baseline').description('Baseline governance (GOVERN operations).');
|
|
28
|
+
baseline.command('accept')
|
|
29
|
+
.description('Accept a proposed baseline. Requires a human approval pointer.')
|
|
30
|
+
.option('--from <file>', 'proposed-baseline.json from an audit')
|
|
31
|
+
.option('--approved-by <human>', 'the approving human')
|
|
32
|
+
.option('--approval-ref <ref>', 'pointer to where approval was given (message/PR/decision note)')
|
|
33
|
+
.action((opts) => baselineAcceptCommand(process.cwd(), opts));
|
|
34
|
+
const waiver = program.command('waiver').description('Waiver protocol: agents draft, humans approve.');
|
|
35
|
+
waiver.command('draft')
|
|
36
|
+
.description('Draft a waiver for a finding. It stays inactive (and flagged) until a human fills the approval fields.')
|
|
37
|
+
.option('--fingerprint <fp>', 'the 16-hex fingerprint from check output')
|
|
38
|
+
.option('--gate <gate>', 'duplication | boundaries | patterns | registry | contracts')
|
|
39
|
+
.option('--scope <path>', 'file or area the finding is in')
|
|
40
|
+
.option('--reason <text>', 'why the policy is wrong here')
|
|
41
|
+
.option('--expires-in-days <n>', 'days until expiry (default 90)')
|
|
42
|
+
.action((opts) => waiverDraftCommand(process.cwd(), opts));
|
|
43
|
+
program.parseAsync(process.argv);
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
const ICON = {
|
|
3
|
+
PASS: pc.green('PASS'),
|
|
4
|
+
PASS_WITH_FINDINGS: pc.yellow('PASS*'),
|
|
5
|
+
FAIL: pc.red('FAIL'),
|
|
6
|
+
ERROR: pc.red('ERROR'),
|
|
7
|
+
SKIPPED: pc.dim('SKIP'),
|
|
8
|
+
};
|
|
9
|
+
export function renderResults(results, verbose) {
|
|
10
|
+
const lines = [];
|
|
11
|
+
for (const r of results) {
|
|
12
|
+
const counts = r.findings.length > 0 || r.baselined > 0 || r.waived > 0
|
|
13
|
+
? pc.dim(` (${r.newFindings.length} new, ${r.baselined} baselined, ${r.waived} waived)`)
|
|
14
|
+
: '';
|
|
15
|
+
lines.push(`${ICON[r.status].padEnd(14)} ${r.gate}${counts}${r.error ? pc.red(` — ${r.error}`) : ''}${r.detail ? pc.dim(` — ${r.detail}`) : ''}`);
|
|
16
|
+
const shown = verbose ? r.newFindings : r.newFindings.slice(0, 10);
|
|
17
|
+
for (const f of shown) {
|
|
18
|
+
lines.push(pc.dim(` [${f.fingerprint}] ${f.rule}: ${f.path} — ${f.message}`));
|
|
19
|
+
}
|
|
20
|
+
if (!verbose && r.newFindings.length > 10) {
|
|
21
|
+
lines.push(pc.dim(` … ${r.newFindings.length - 10} more (use --verbose)`));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return lines.join('\n');
|
|
25
|
+
}
|
|
26
|
+
export function renderMarkdownReport(results, date) {
|
|
27
|
+
const rows = results.map((r) => `| ${r.gate} | ${r.status} | ${r.findings.length} | ${r.newFindings.length} | ${r.baselined} | ${r.waived} |${r.error ?? r.detail ?? ''}|`);
|
|
28
|
+
const details = results.flatMap((r) => r.newFindings.map((f) => `- \`${f.fingerprint}\` **${r.gate}/${f.rule}** ${f.path} — ${f.message}`));
|
|
29
|
+
return [
|
|
30
|
+
`# ASDLC audit — ${date}`,
|
|
31
|
+
'',
|
|
32
|
+
'Raw scanner results are evidence for review, not proof of a product defect.',
|
|
33
|
+
'',
|
|
34
|
+
'| Gate | Status | Findings | New | Baselined | Waived | Note |',
|
|
35
|
+
'| --- | --- | ---: | ---: | ---: | ---: | --- |',
|
|
36
|
+
...rows,
|
|
37
|
+
'',
|
|
38
|
+
'## New findings',
|
|
39
|
+
...(details.length > 0 ? details : ['None.']),
|
|
40
|
+
'',
|
|
41
|
+
'## Blind spots (standing)',
|
|
42
|
+
'- Clone detection finds textual similarity, not semantic equivalence.',
|
|
43
|
+
'- Boundary checks prove only what the approved rules express.',
|
|
44
|
+
'- Import-orphan analysis cannot see business orphans (imported but unconsumed).',
|
|
45
|
+
'- Consumer discovery is evidence, not completeness.',
|
|
46
|
+
].join('\n');
|
|
47
|
+
}
|
package/dist/runner.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { loadBaseline, evaluateGate } from './baseline.js';
|
|
2
|
+
import { loadWaivers, activeWaiverFingerprints } from './waivers.js';
|
|
3
|
+
import { runDuplicationGate } from './gates/duplication.js';
|
|
4
|
+
import { runBoundariesGate } from './gates/boundaries.js';
|
|
5
|
+
import { runPatternsGate } from './gates/patterns.js';
|
|
6
|
+
import { runRegistryGate } from './gates/registry.js';
|
|
7
|
+
import { runContractsGate } from './gates/contracts.js';
|
|
8
|
+
export async function runAllGates(root, config, overrides) {
|
|
9
|
+
const baseline = loadBaseline(root);
|
|
10
|
+
const waivers = activeWaiverFingerprints(loadWaivers(root));
|
|
11
|
+
const collectors = {
|
|
12
|
+
duplication: async () => runDuplicationGate(root, config),
|
|
13
|
+
boundaries: () => runBoundariesGate(root, config),
|
|
14
|
+
patterns: async () => runPatternsGate(root, config),
|
|
15
|
+
registry: async () => runRegistryGate(root, config),
|
|
16
|
+
contracts: async () => runContractsGate(root, config),
|
|
17
|
+
...overrides,
|
|
18
|
+
};
|
|
19
|
+
const results = [];
|
|
20
|
+
for (const gate of Object.keys(collectors)) {
|
|
21
|
+
if (!config.gates[gate]?.enabled) {
|
|
22
|
+
results.push({ gate, status: 'SKIPPED', findings: [], newFindings: [],
|
|
23
|
+
baselined: 0, waived: 0, detail: 'disabled in .asdlc/config.yml' });
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const out = await collectors[gate]();
|
|
28
|
+
if (out === 'UNCONFIGURED') {
|
|
29
|
+
results.push({ gate, status: 'SKIPPED', findings: [], newFindings: [],
|
|
30
|
+
baselined: 0, waived: 0, detail: 'not configured — SKIPPED is not PASS' });
|
|
31
|
+
}
|
|
32
|
+
else if (out === 'TOOL_MISSING') {
|
|
33
|
+
// Enabled but tool absent: the check cannot run, so it reports ERROR, never PASS.
|
|
34
|
+
results.push({ gate, status: 'ERROR', findings: [], newFindings: [],
|
|
35
|
+
baselined: 0, waived: 0, error: 'scanner not installed (e.g. semgrep missing from PATH)' });
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
results.push(evaluateGate(gate, out, baseline, waivers, config.mode));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
results.push({ gate, status: 'ERROR', findings: [], newFindings: [],
|
|
43
|
+
baselined: 0, waived: 0, error: err instanceof Error ? err.message : String(err) });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return results;
|
|
47
|
+
}
|
|
48
|
+
export function overallExitCode(results) {
|
|
49
|
+
return results.some((r) => r.status === 'FAIL' || r.status === 'ERROR') ? 1 : 0;
|
|
50
|
+
}
|
package/dist/state.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { parse, stringify } from 'yaml';
|
|
4
|
+
export const ASDLC_DIR = '.asdlc';
|
|
5
|
+
export function statePath(root) {
|
|
6
|
+
return join(root, ASDLC_DIR, 'state.yml');
|
|
7
|
+
}
|
|
8
|
+
export function readState(root) {
|
|
9
|
+
const p = statePath(root);
|
|
10
|
+
if (!existsSync(p))
|
|
11
|
+
return null;
|
|
12
|
+
const raw = parse(readFileSync(p, 'utf8'));
|
|
13
|
+
if (!raw || typeof raw !== 'object')
|
|
14
|
+
return null;
|
|
15
|
+
// Marker presence alone is not installation (S0): an unreadable or
|
|
16
|
+
// incomplete state file is PARTIAL, never assumed ACTIVE.
|
|
17
|
+
return {
|
|
18
|
+
schema_version: raw.schema_version ?? 1,
|
|
19
|
+
playbook_version: raw.playbook_version ?? '3.1.1',
|
|
20
|
+
status: raw.status ?? 'PARTIAL',
|
|
21
|
+
mode: raw.mode ?? 'legacy',
|
|
22
|
+
config_revision: raw.config_revision ?? 1,
|
|
23
|
+
baseline_revision: raw.baseline_revision ?? 1,
|
|
24
|
+
last_audit: raw.last_audit ?? null,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export function writeState(root, state) {
|
|
28
|
+
writeFileSync(statePath(root), stringify(state));
|
|
29
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/waivers.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { parse } from 'yaml';
|
|
4
|
+
import { ASDLC_DIR } from './state.js';
|
|
5
|
+
const REQUIRED = [
|
|
6
|
+
'id', 'gate', 'scope', 'fingerprint', 'reason',
|
|
7
|
+
'approved_by', 'approval_ref', 'approved_on', 'expires_on',
|
|
8
|
+
];
|
|
9
|
+
export function loadWaivers(root, today = new Date()) {
|
|
10
|
+
const p = join(root, ASDLC_DIR, 'waivers.yml');
|
|
11
|
+
const result = { valid: [], expired: [], malformed: [] };
|
|
12
|
+
if (!existsSync(p))
|
|
13
|
+
return result;
|
|
14
|
+
const doc = parse(readFileSync(p, 'utf8'));
|
|
15
|
+
for (const entry of doc?.waivers ?? []) {
|
|
16
|
+
const w = entry;
|
|
17
|
+
const missing = REQUIRED.filter((k) => !w[k] || String(w[k]).trim() === '');
|
|
18
|
+
if (missing.length > 0) {
|
|
19
|
+
result.malformed.push({ entry, missing });
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
// Expiry ends suppression: the finding turns red until renewed or fixed (SW).
|
|
23
|
+
if (new Date(w.expires_on) < today)
|
|
24
|
+
result.expired.push(w);
|
|
25
|
+
else
|
|
26
|
+
result.valid.push(w);
|
|
27
|
+
}
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
export function activeWaiverFingerprints(check) {
|
|
31
|
+
return new Set(check.valid.map((w) => w.fingerprint));
|
|
32
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "asdlc-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "ASDLC — the anti-drift governance layer for AI-built codebases. Implements the Anti-Drift Playbook v3.1.1 protocol: plan, init, check, audit.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": { "type": "git", "url": "git+https://github.com/chennurivarun/asdlc.git", "directory": "packages/cli" },
|
|
8
|
+
"homepage": "https://github.com/chennurivarun/asdlc#readme",
|
|
9
|
+
"bugs": { "url": "https://github.com/chennurivarun/asdlc/issues" },
|
|
10
|
+
"keywords": ["ai", "coding-agents", "code-quality", "governance", "drift", "duplication", "architecture", "ci", "claude-code", "cursor", "sdlc"],
|
|
11
|
+
"bin": { "asdlc": "./bin/asdlc.js" },
|
|
12
|
+
"files": ["dist", "bin", "assets"],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc -p tsconfig.json",
|
|
15
|
+
"dev": "tsx src/index.ts",
|
|
16
|
+
"test": "vitest run",
|
|
17
|
+
"prepublishOnly": "npm run build && npm test"
|
|
18
|
+
},
|
|
19
|
+
"engines": { "node": ">=20" },
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"commander": "^12.1.0",
|
|
22
|
+
"dependency-cruiser": "^16.10.0",
|
|
23
|
+
"jscpd": "^4.0.5",
|
|
24
|
+
"picocolors": "^1.1.1",
|
|
25
|
+
"yaml": "^2.6.0"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/node": "^22.10.0",
|
|
29
|
+
"tsx": "^4.19.0",
|
|
30
|
+
"typescript": "^5.7.0",
|
|
31
|
+
"vitest": "^2.1.0"
|
|
32
|
+
}
|
|
33
|
+
}
|