arkgate 4.8.3 → 4.8.4
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/CHANGELOG.md +242 -0
- package/README.md +10 -3
- package/bin/ark-check-runtime.mjs +340 -5
- package/bin/ark-layer-match.mjs +170 -13
- package/bin/ark-mcp-runtime.mjs +9 -2
- package/bin/lib/analysis-completeness.mjs +86 -0
- package/bin/lib/analysis-engine.mjs +5 -5
- package/bin/lib/architecture-scan.mjs +2 -0
- package/bin/lib/arkrules-contract.mjs +8 -1
- package/bin/lib/check-args.mjs +66 -0
- package/bin/lib/config-contract.mjs +26 -0
- package/bin/lib/design-smells.mjs +85 -0
- package/bin/lib/diagnostic-catalog.mjs +6 -1
- package/bin/lib/first-run-help.mjs +12 -0
- package/bin/lib/invariant-coverage-io.mjs +175 -19
- package/bin/lib/invariant-coverage.mjs +110 -7
- package/bin/lib/literal-path-drift-io.mjs +569 -0
- package/bin/lib/literal-path-drift.mjs +761 -0
- package/bin/lib/policy-delta-io.mjs +5 -0
- package/bin/lib/remediation.mjs +15 -0
- package/bin/lib/rules-under-contract.mjs +5 -0
- package/bin/lib/scan-files.mjs +54 -0
- package/bin/lib/sensor-promote-cli.mjs +372 -0
- package/bin/lib/sensor-promote-io.mjs +246 -0
- package/bin/lib/sensor-promotion.mjs +363 -0
- package/dist/{configTypes-dNJ2C0yx.d.ts → configTypes-dy5PfTqS.d.ts} +31 -0
- package/dist/{diagnosticCatalog-C5GgeyEE.d.ts → diagnosticCatalog-DgTs0abp.d.ts} +75 -7
- package/dist/eslint/index.cjs +6 -6
- package/dist/eslint/index.d.ts +34 -1
- package/dist/eslint/index.js +6 -6
- package/dist/index.cjs +32 -32
- package/dist/index.d.ts +65 -4
- package/dist/index.js +29 -29
- package/dist/nestjs/index.cjs +5 -5
- package/dist/nestjs/index.d.ts +3 -3
- package/dist/nestjs/index.js +5 -5
- package/dist/runtime/index.cjs +15 -15
- package/dist/runtime/index.d.ts +6 -6
- package/dist/runtime/index.js +15 -15
- package/dist/{types-dK24fDZa.d.ts → types-BuM8WNqe.d.ts} +1 -1
- package/dist/{types-DeK7SYGC.d.ts → types-D95drJ3_.d.ts} +1 -1
- package/docs/README.md +1 -1
- package/docs/agent-guide.md +182 -0
- package/docs/configuration.md +77 -1
- package/docs/develop.md +1 -0
- package/docs/diagnostics.md +70 -1
- package/docs/package-surface.md +32 -2
- package/package.json +2 -2
- package/schemas/ark.config.schema.json +63 -0
- package/server.json +3 -3
- package/templates/agent-skills/ark-adopt/SKILL.md +5 -0
- package/templates/agent-skills/ark-coverage/SKILL.md +1 -0
- package/templates/skills/ark-adopt.md +5 -0
- package/templates/skills/ark-coverage.md +1 -0
|
@@ -12,6 +12,7 @@ import { effectiveAnalysisConfig } from './analysis-policy.mjs';
|
|
|
12
12
|
import { resolveCandidateFacts } from './resolved-candidate-facts.mjs';
|
|
13
13
|
import { loadEffectiveArkRulesFromDisk } from './effective-contract-load.mjs';
|
|
14
14
|
import {
|
|
15
|
+
coverageOptionsFromConfig,
|
|
15
16
|
invariantIdsFromCatalog,
|
|
16
17
|
loadInvariantCoverageInputs,
|
|
17
18
|
} from './invariant-coverage-io.mjs';
|
|
@@ -72,6 +73,7 @@ export function resolveArchitectureSnapshot({
|
|
|
72
73
|
const coverageInputs = hasInvariants
|
|
73
74
|
? loadInvariantCoverageInputs(root, facts, {
|
|
74
75
|
invariantIds: invariantIdsFromCatalog(arkRulesLoad.arkRules),
|
|
76
|
+
...coverageOptionsFromConfig(effectiveConfig),
|
|
75
77
|
})
|
|
76
78
|
: undefined;
|
|
77
79
|
// AR07: Tooling fileHints for orchestration-only / thin-adapter (reuse coverage contents when present).
|
|
@@ -251,9 +251,16 @@ function validateSemantics(candidate, issues) {
|
|
|
251
251
|
if (typeof entry.sensor === 'string' &&
|
|
252
252
|
isTier2Sensor(entry.sensor) &&
|
|
253
253
|
entry.mode === 'enforced') {
|
|
254
|
+
// Name the rule the AUTHOR wrote, not only the sensor it delegates to.
|
|
255
|
+
// A rule called "types-only" on `no-anemic-model` used to be refused with
|
|
256
|
+
// an error naming an id that appears nowhere in the author's file, and
|
|
257
|
+
// finding the connection cost a full run each time. `--sensors` reports
|
|
258
|
+
// the same fact before the attempt.
|
|
254
259
|
issues.push({
|
|
255
260
|
path: `$.structure[${index}].mode`,
|
|
256
|
-
message: `
|
|
261
|
+
message: `${id ? `rule ${JSON.stringify(id)} uses sensor ` : 'sensor '}` +
|
|
262
|
+
`${JSON.stringify(entry.sensor)}, which is Tier-2 advisory-only and cannot be enforced` +
|
|
263
|
+
`${id ? ' (arkgate-check --sensors lists which sensors can)' : ''}`,
|
|
257
264
|
});
|
|
258
265
|
}
|
|
259
266
|
if (Array.isArray(entry.appliesTo) && entry.appliesTo.length === 0) {
|
package/bin/lib/check-args.mjs
CHANGED
|
@@ -51,6 +51,10 @@ export function parseArgs(argv) {
|
|
|
51
51
|
migrateCommands: false,
|
|
52
52
|
doctor: false,
|
|
53
53
|
plan: false,
|
|
54
|
+
pathDrift: false,
|
|
55
|
+
sensors: false,
|
|
56
|
+
promote: undefined,
|
|
57
|
+
apply: false,
|
|
54
58
|
recommend: false,
|
|
55
59
|
writePlan: false,
|
|
56
60
|
listPolicyPacks: false,
|
|
@@ -112,6 +116,24 @@ export function parseArgs(argv) {
|
|
|
112
116
|
else if (arg === '--doctor') args.doctor = true;
|
|
113
117
|
else if (arg === '--plan') args.plan = true;
|
|
114
118
|
else if (arg === '--rules-inventory') args.rulesInventory = true;
|
|
119
|
+
else if (arg === '--path-drift') args.pathDrift = true;
|
|
120
|
+
else if (arg === '--sensors') args.sensors = true;
|
|
121
|
+
else if (arg === '--promote' || arg.startsWith('--promote=')) {
|
|
122
|
+
// Optional value, same shape as --report / --baseline: bare --promote
|
|
123
|
+
// previews every declared rule from ONE run, which is the point (the loop
|
|
124
|
+
// it replaces was one full run per attempted promotion); a value narrows
|
|
125
|
+
// to that rule id.
|
|
126
|
+
// `--promote=<id>` too: a rule id may legally begin with '-', and the
|
|
127
|
+
// lookahead form can never pass one.
|
|
128
|
+
const eq = arg.indexOf('=');
|
|
129
|
+
if (eq > 0) {
|
|
130
|
+
args.promote = arg.slice(eq + 1);
|
|
131
|
+
} else {
|
|
132
|
+
const next = argv[i + 1];
|
|
133
|
+
args.promote = next && !next.startsWith('-') ? argv[++i] : true;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
else if (arg === '--apply') args.apply = true;
|
|
115
137
|
else if (arg === '--recommend') args.recommend = true;
|
|
116
138
|
else if (arg === '--write-plan') args.writePlan = true;
|
|
117
139
|
else if (arg === '--list-policy-packs') args.listPolicyPacks = true;
|
|
@@ -170,5 +192,49 @@ export function parseArgs(argv) {
|
|
|
170
192
|
else if (arg === '--version' || arg === '-V') args.version = true;
|
|
171
193
|
else throw new Error(`Unknown argument: ${arg}. Run arkgate-check --help for usage.`);
|
|
172
194
|
}
|
|
195
|
+
// A write flag that silently does nothing is how a caller comes to believe a
|
|
196
|
+
// change landed. --apply belongs to --promote; the other write surfaces use
|
|
197
|
+
// --write.
|
|
198
|
+
if (args.apply && !args.promote) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
'--apply applies to --promote. Run `arkgate-check --promote <ruleId> --apply`; the other write surfaces use --write.'
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
// Every report mode below returns from main() BEFORE --promote is reached, so
|
|
204
|
+
// pairing one with --promote --apply printed that report and exited 0 having
|
|
205
|
+
// written nothing. Same defect the guard above exists for, one level up.
|
|
206
|
+
if (args.promote) {
|
|
207
|
+
const shadowing = [
|
|
208
|
+
args.sensors && '--sensors',
|
|
209
|
+
args.pathDrift && '--path-drift',
|
|
210
|
+
args.coverage && '--coverage',
|
|
211
|
+
args.recommend && '--recommend',
|
|
212
|
+
args.doctor && '--doctor',
|
|
213
|
+
args.plan && '--plan',
|
|
214
|
+
args.rulesInventory && '--rules-inventory',
|
|
215
|
+
args.suggestInclude && '--suggest-include',
|
|
216
|
+
args.adoptContract && '--adopt-contract',
|
|
217
|
+
args.migrateContract && '--migrate-contract',
|
|
218
|
+
].filter(Boolean);
|
|
219
|
+
if (shadowing.length > 0) {
|
|
220
|
+
throw new Error(
|
|
221
|
+
`--promote cannot be combined with ${shadowing.join(', ')}: that mode answers first and --promote would silently do nothing. Run them separately.`
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
// A narrowed scope cannot price a promotion. --changed counts findings over
|
|
225
|
+
// the changed files only and --baseline suppresses findings AFTER --promote
|
|
226
|
+
// has already returned, so either one turns the cost into an undercount
|
|
227
|
+
// printed as a fact.
|
|
228
|
+
const narrowing = [
|
|
229
|
+
args.changed && '--changed',
|
|
230
|
+
args.against && '--against',
|
|
231
|
+
args.baseline && '--baseline',
|
|
232
|
+
].filter(Boolean);
|
|
233
|
+
if (narrowing.length > 0) {
|
|
234
|
+
throw new Error(
|
|
235
|
+
`--promote cannot be combined with ${narrowing.join(', ')}: the promotion cost must be measured over the whole governed tree, and a narrowed or baselined scope would report an undercount as the price.`
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
173
239
|
return args;
|
|
174
240
|
}
|
|
@@ -105,6 +105,8 @@ export const ARK_CONFIG_SCHEMA = {
|
|
|
105
105
|
allowDisabledPeerIsolation: false,
|
|
106
106
|
},
|
|
107
107
|
},
|
|
108
|
+
/** Invariant-coverage scan controls (test globs + file budget). Absence keeps defaults. */
|
|
109
|
+
coverage: { $ref: '#/$defs/coverage' },
|
|
108
110
|
/** ADR 0012 — layer name → relative path to arkrules/<Layer>.json */
|
|
109
111
|
arkRules: {
|
|
110
112
|
type: 'object',
|
|
@@ -172,6 +174,20 @@ export const ARK_CONFIG_SCHEMA = {
|
|
|
172
174
|
message: { type: 'string', minLength: 1 },
|
|
173
175
|
peerIsolation: { type: 'boolean' },
|
|
174
176
|
sliceFolders: { ...stringArraySchema, minItems: 1 },
|
|
177
|
+
sharedRoots: { ...stringArraySchema, minItems: 1 },
|
|
178
|
+
allowedCrossSlice: {
|
|
179
|
+
type: 'array',
|
|
180
|
+
minItems: 1,
|
|
181
|
+
items: {
|
|
182
|
+
type: 'object',
|
|
183
|
+
additionalProperties: false,
|
|
184
|
+
required: ['from', 'to'],
|
|
185
|
+
properties: {
|
|
186
|
+
from: { type: 'string', minLength: 1 },
|
|
187
|
+
to: { type: 'string', minLength: 1 },
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
},
|
|
175
191
|
},
|
|
176
192
|
},
|
|
177
193
|
safety: {
|
|
@@ -184,6 +200,16 @@ export const ARK_CONFIG_SCHEMA = {
|
|
|
184
200
|
allowDisabledPeerIsolation: { type: 'boolean', default: false },
|
|
185
201
|
},
|
|
186
202
|
},
|
|
203
|
+
coverage: {
|
|
204
|
+
type: 'object',
|
|
205
|
+
additionalProperties: false,
|
|
206
|
+
description: 'Invariant coverage scan controls. testGlobs replaces the built-in test-name heuristic; maxFiles raises or lowers the evidence file budget; coverageRoots declares where the project runs its tests, so a covering test found outside them is reported instead of silently certifying an invariant.',
|
|
207
|
+
properties: {
|
|
208
|
+
testGlobs: { ...stringArraySchema, minItems: 1 },
|
|
209
|
+
maxFiles: { type: 'integer', minimum: 1 },
|
|
210
|
+
coverageRoots: { ...stringArraySchema, minItems: 1 },
|
|
211
|
+
},
|
|
212
|
+
},
|
|
187
213
|
arkRun: ARK_RUN_SCHEMA_DEF,
|
|
188
214
|
arkOrder: ARK_ORDER_SCHEMA_DEF,
|
|
189
215
|
},
|
|
@@ -490,6 +490,91 @@ export function summarizeDesignFitness(smells, ctx = {}) {
|
|
|
490
490
|
};
|
|
491
491
|
}
|
|
492
492
|
|
|
493
|
+
/** How many smell ids the green-run pointer names before it counts the rest. */
|
|
494
|
+
export const GREEN_PLAN_POINTER_MAX_IDS = 4;
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Name `--plan` on a run that passed.
|
|
498
|
+
*
|
|
499
|
+
* `--plan` is where the design bets live, and a green run never pointed at it:
|
|
500
|
+
* a whole adopter session went by without opening it, because `✔ Ark check
|
|
501
|
+
* passed` reads as *finished*. Clean import edges are not a settled design, and
|
|
502
|
+
* the summary was saying only the first half of what the run already knows.
|
|
503
|
+
*
|
|
504
|
+
* Report only, and deliberately quiet. Its weakness test is `isDesignWeak` with
|
|
505
|
+
* the **blocking** violation count, the same input doctor uses, so the two
|
|
506
|
+
* surfaces can never disagree about `designWeak`. It never changes the exit
|
|
507
|
+
* code, and it stays silent when there is nothing behind the pass — a line that
|
|
508
|
+
* always prints is a line nobody reads. The caller passes the runner-correct
|
|
509
|
+
* command so this stays pure.
|
|
510
|
+
*
|
|
511
|
+
* A baselined run is not a clean run: violations suppressed by the ratchet are
|
|
512
|
+
* still violations, and the summary line directly above says so. The opening
|
|
513
|
+
* clause reports which of the two it is.
|
|
514
|
+
*
|
|
515
|
+
* @param {DesignSmell[]} smells
|
|
516
|
+
* @param {{ blockingViolations?: number, governedPercent?: number|null, totalFiles?: number|null, suppressedCount?: number }} ctx
|
|
517
|
+
* @param {string} planCommand
|
|
518
|
+
* @returns {string | null}
|
|
519
|
+
*/
|
|
520
|
+
export function formatGreenPlanPointer(smells, ctx = {}, planCommand = 'ark-check --plan') {
|
|
521
|
+
const weak = isDesignWeak(smells, {
|
|
522
|
+
activeViolations: ctx.blockingViolations ?? 0,
|
|
523
|
+
governedPercent: ctx.governedPercent ?? null,
|
|
524
|
+
totalFiles: ctx.totalFiles ?? null,
|
|
525
|
+
});
|
|
526
|
+
if (!weak) return null;
|
|
527
|
+
const ids = [...new Set((smells || []).map((smell) => smell?.id).filter(Boolean))].sort();
|
|
528
|
+
if (ids.length === 0) return null;
|
|
529
|
+
const shown = ids.slice(0, GREEN_PLAN_POINTER_MAX_IDS);
|
|
530
|
+
const hidden = ids.length - shown.length;
|
|
531
|
+
const named = hidden > 0 ? `${shown.join(', ')}, +${hidden} more` : shown.join(', ');
|
|
532
|
+
const plural = ids.length === 1 ? '' : 's';
|
|
533
|
+
const suppressed = ctx.suppressedCount ?? 0;
|
|
534
|
+
const opening =
|
|
535
|
+
suppressed > 0
|
|
536
|
+
? `No blocking import-rule violations (${suppressed} suppressed by baseline)`
|
|
537
|
+
: 'Import rules are clean';
|
|
538
|
+
return (
|
|
539
|
+
`${opening}; the design bets are not settled — ${ids.length} design smell${plural} ` +
|
|
540
|
+
`(${named}). They never fail this check: ${planCommand}`
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* The same pointer, composed against the tree the run just analyzed.
|
|
546
|
+
*
|
|
547
|
+
* Kept here rather than in the CLI entry: the smell scan is filesystem work and
|
|
548
|
+
* `bin/ark-check-runtime.mjs` is orchestration under a line budget. A failure
|
|
549
|
+
* is swallowed on purpose — a discoverability hint must never change the
|
|
550
|
+
* outcome of a green run — but it is not silent under `ARK_DEBUG`, or a
|
|
551
|
+
* refactor could leave this permanently dead with no signal anywhere.
|
|
552
|
+
*
|
|
553
|
+
* @param {{ root: string, config: object, files: string[], coverage: object|null,
|
|
554
|
+
* blockingViolations: number, suppressedCount?: number, planCommand: string }} options
|
|
555
|
+
* @returns {string | null}
|
|
556
|
+
*/
|
|
557
|
+
export function greenPlanPointer(options) {
|
|
558
|
+
try {
|
|
559
|
+
const { root, config, files, coverage } = options;
|
|
560
|
+
return formatGreenPlanPointer(
|
|
561
|
+
detectDesignSmells(root, config, files, coverage),
|
|
562
|
+
{
|
|
563
|
+
blockingViolations: options.blockingViolations,
|
|
564
|
+
governedPercent: coverage?.governed?.percent ?? null,
|
|
565
|
+
totalFiles: coverage?.totalFiles ?? null,
|
|
566
|
+
suppressedCount: options.suppressedCount ?? 0,
|
|
567
|
+
},
|
|
568
|
+
options.planCommand
|
|
569
|
+
);
|
|
570
|
+
} catch (error) {
|
|
571
|
+
if (process.env.ARK_DEBUG === '1') {
|
|
572
|
+
console.error(`[ark-check] green --plan pointer skipped: ${error?.message ?? error}`);
|
|
573
|
+
}
|
|
574
|
+
return null;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
493
578
|
/**
|
|
494
579
|
* Build plan-B pattern bets from smells (P03). Never mechanical-safe.
|
|
495
580
|
*
|
|
@@ -55,7 +55,8 @@ export const DIAGNOSTIC_CATALOG = Object.freeze([
|
|
|
55
55
|
entry('ARKRULE_STRUCTURE', 'arkrules', 'ArkRule structure sensor failed', 'An opt-in ArkRules structure sensor (private state, factory shape, event publish, persistence write outside an aggregate, …) failed on a governed file for a declared arkruleId.', 'Restore the declared structure for the ArkRule (see arkruleSource), then preflight again. Do not demote the rule without a hash-bound policy acknowledgement.'),
|
|
56
56
|
entry('ARKRULE_INVARIANT', 'arkrules', 'ArkRule invariant failed', 'Reserved / remediation-recognized code for invariant-plane failures bound to an ArkRule id (coverage path also emits INVARIANT_UNCOVERED).', 'Fix the invariant for the ArkRule declared in arkrules/<Layer>.json, then preflight again. Do not demote without acknowledgement.'),
|
|
57
57
|
entry('ARKRULE_SCOPE_EMPTY', 'arkrules', 'ArkRule appliesTo matched zero files', 'An ArkRule’s appliesTo globs matched no governed files — the rule cannot observe what it claims to protect.', 'Fix appliesTo globs so they match governed files, or remove the rule. Enforced empty scope fails; advisory empty scope warns.', { oftenAdvisory: true }),
|
|
58
|
-
entry('INVARIANT_UNCOVERED', 'arkrules', 'Invariant without coverage evidence', 'An ArkRules invariant is under contract but no covering test title or declared symbol evidence was found (or coverage is partial). Kind is never-had-tests (adopt residual) vs tests-disappeared (suite exists).', 'Add a test title or declared symbol covering the arkruleId, then preflight again. Treat never-had-tests as adopt residual; treat tests-disappeared as a regression. Missing test globs report partial — never fake green.'),
|
|
58
|
+
entry('INVARIANT_UNCOVERED', 'arkrules', 'Invariant without coverage evidence', 'An ArkRules invariant is under contract but no covering test title or declared symbol evidence was found (or coverage is partial). Kind is never-had-tests (adopt residual) vs tests-disappeared (suite exists).', 'Add a test title or declared symbol covering the arkruleId, then preflight again. Treat never-had-tests as adopt residual; treat tests-disappeared as a regression. Missing test globs report partial — never fake green. When the message reports an exhausted file budget, raise coverage.maxFiles (or narrow coverage.testGlobs) in ark.config.json.'),
|
|
59
|
+
entry('INVARIANT_COVERAGE_OUTSIDE_ROOTS', 'arkrules', 'Covering test outside the declared coverage roots', 'The only test naming this invariant sits outside coverage.coverageRoots — the places the project declares its runner executes. ArkGate matches declared text and never executes tests, so it cannot tell whether that file is ever run: coverage there is a test that exists, not a test that runs.', 'Move the test under a declared coverage root, or add its root to coverage.coverageRoots in ark.config.json. Advisory: it never fails strict, but promotion to enforced refuses on it.', { oftenAdvisory: true }),
|
|
59
60
|
// ── ArkRun (opt-in extra; RN05 dual-depth nextAction) ────────────────────
|
|
60
61
|
entry('ARKRUN_MISSING_ROOT', 'arkrun', 'No kernel factory in composition roots', 'The ArkRun extra is on but no createArkKernel / createStrictArkKernel / createArkKernelFromConfig / createStrictArkKernelFromConfig factory was found in arkRun.compositionRoots, so agents can skip the kernel while the write gate stays green.', 'Import createStrictArkKernel from arkgate/runtime (same npm package; @arkgate/runtime is deprecated) and call it in a composition root listed in arkRun.compositionRoots, then preflight again. Never mechanical-safe — factory placement is a design decision.'),
|
|
61
62
|
entry('ARKRUN_KERNEL_IN_DOMAIN', 'arkrun', 'Domain-role layer imports the kernel', 'A Domain-role layer imports arkgate/runtime, @arkgate/runtime, or kernel types. Domain stays kernel-free; composition roots and adapters own the factory.', 'Move the kernel import out of the Domain-role layer into a composition root or adapter. Import from arkgate/runtime (same npm package; @arkgate/runtime is deprecated), then preflight again. Never mechanical-safe.'),
|
|
@@ -85,6 +86,7 @@ export const DIAGNOSTIC_CATALOG = Object.freeze([
|
|
|
85
86
|
// ── analysis completeness / host ─────────────────────────────────────────
|
|
86
87
|
entry('ANALYSIS_PARSE_INCOMPLETE', 'analysis', 'Parse incomplete', 'Governed source could not be fully parsed; evidence includes the TypeScript diagnostic (line + message). Incremental mid-edit parse is normal for agents. Contract exclude paths skip the write hook.', 'Finish the source or fix the reported syntax error, then re-run `npx arkgate-check`. The write hook does not deny solely on mid-edit parse. Partial never means pass.'),
|
|
87
88
|
entry('LEXICAL_EVIDENCE_INCOMPLETE', 'analysis', 'Lexical evidence incomplete', 'Single-file validation cannot prove project module resolution. The write hook is already the verdict.', 'Re-run `npx arkgate-check --root . --config ark.config.json`, or treat the hook deny as final. Do not call ark_prepare_change from a hook deny.'),
|
|
89
|
+
entry('ANALYSIS_COVERS_NO_FILES', 'analysis', 'Analysis covered no files', 'No file matched the contract include and layer patterns under the analyzed root, so the run had nothing to check. Every rule is vacuously satisfied on an empty set: a green here would read exactly like a green over a governed tree while certifying nothing. Usual causes are a --root that is not the tree the contract describes (including a contract found outside the requested root, whose directory is then adopted as the project root), include / exclude patterns that match nothing, or layer patterns written for a different layout.', 'Point --root at the tree the contract describes, or keep the contract inside that tree, or fix the include / exclude / layer patterns so they match real files — then re-run `npx arkgate-check --root . --config ark.config.json`. This is a refusal about ArkGate\u2019s own inputs, not a finding about your code; no baseline or policy acknowledgement can suppress it.'),
|
|
88
90
|
entry('ANALYSIS_HOST_UNAVAILABLE', 'analysis', 'Analysis host unavailable', 'No usable TypeScript / analysis host was available for this invocation.', 'Install a supported TypeScript version visible to the project, then re-run. Unavailable analysis is fail-closed.'),
|
|
89
91
|
entry('ADAPTER_NOT_ALLOWED_FOR_PORT', 'adapter', 'Adapter not allowed for port', 'Runtime/port wiring selected an adapter implementation that the architecture profile does not allow for that port.', 'Bind an allowed adapter for the port, or adjust the profile with an explicit policy decision — then re-run.'),
|
|
90
92
|
// ── AI snippet gate policy surface ───────────────────────────────────────
|
|
@@ -109,6 +111,9 @@ export const DIAGNOSTIC_CATALOG = Object.freeze([
|
|
|
109
111
|
entry('CONFIG_RULE_UNKNOWN_TO_LAYER', 'config', 'Rule unknown to layer', 'A dependency rule references a target layer name that is not declared.', 'Fix the rule’s to field to a declared layer name.', { oftenAdvisory: true }),
|
|
110
112
|
entry('CONFIG_AMBIGUOUS_LAYERS', 'config', 'Ambiguous layer classification', 'Some files match multiple layers at equal specificity; classification falls back to declaration order.', 'Disambiguate overlapping patterns so each file has one clear layer owner.', { oftenAdvisory: true }),
|
|
111
113
|
entry('CONFIG_UNCLASSIFIED_FILES', 'config', 'Unclassified included files', 'Included source files match no layer pattern; import rules will not enforce on them.', 'Extend layer patterns or narrow include so every governed file is classified.', { oftenAdvisory: true }),
|
|
114
|
+
// ── literal path drift ───────────────────────────────────────────────────
|
|
115
|
+
entry('LITERAL_PATH_DRIFT', 'drift', 'Literal path moved by a rename', 'A repo path written inside a string, a comment or a docstring no longer resolves, and the rename set says where it went. Nothing in the gate sees this class: `tsc` resolves imports, not strings, and ESLint does not either, so the rename compiles green and the reference lies afterwards. It appears in four forms — the tsconfig alias, a relative literal, a path written without the include-root prefix, and prose — and a hand sweep reliably covers one of them.', 'Apply the suggested replacement, or re-run `npx arkgate-check --path-drift --base-ref <ref> --write` to apply every writable anchored replacement at once. The rewrite is mechanical and one-directional: the destination comes from the rename, it must itself resolve and be path-shaped, and the token is rewritten in the form the author wrote it in. A destination that leaves the alias root of the literal is reported with the target only and must be rewritten by hand.'),
|
|
116
|
+
entry('LITERAL_PATH_UNRESOLVED', 'drift', 'Literal path does not resolve', 'A literal that looks like a repo path does not resolve under this root, and no rename explains where it went. Unlike LITERAL_PATH_DRIFT this is a candidate, not a verdict: with nothing to anchor it, ArkGate cannot tell a dead reference from an illustrative path in a comment, an example in documentation, or a path belonging to another tree.', 'Read the candidate and decide: fix the path, or leave it. Advisory only — it never fails a run and is never rewritten by --write, because there is no destination to propose. Run `--path-drift --all` to list the sweep.', { oftenAdvisory: true }),
|
|
112
117
|
// ── meta ─────────────────────────────────────────────────────────────────
|
|
113
118
|
entry('ARK_UNKNOWN', 'meta', 'Unknown diagnostic', 'A diagnostic lacked a stable ruleId/code; adapters may emit this fallback so agents never see an empty id.', 'Resolve the underlying finding without weakening ark.config.json, then run Ark again. Prefer fixing the producer to emit a catalogued ruleId.'),
|
|
114
119
|
]);
|
|
@@ -117,6 +117,18 @@ export function checkUsageAll() {
|
|
|
117
117
|
' ark-check --coverage [--json] per-layer file counts + full unclassified list (report only, exit 0)',
|
|
118
118
|
' ark-check --plan [--json] classified remediation plan (mechanical-safe / judgment / deferred) + goal; report only',
|
|
119
119
|
' ark-check --rules-inventory [--json] brownfield rules inventory (AR13; deterministic candidates, not a score)',
|
|
120
|
+
' ark-check --path-drift [--base-ref <git-ref>] [--all] [--json] [--write]',
|
|
121
|
+
' literal path drift: repo paths inside strings, comments and docstrings that no longer resolve.',
|
|
122
|
+
' Anchored on the rename set vs --base-ref, so each finding carries a replacement; --write applies them.',
|
|
123
|
+
' --all adds the unanchored advisory sweep (candidates, not a verdict; never written).',
|
|
124
|
+
' Exit 0 ran and clean, 1 drift remains, 2 could not run (no usable base ref).',
|
|
125
|
+
' ark-check --sensors [--json] every sensor with its tier and whether it can EVER be enforced, plus every declared rule',
|
|
126
|
+
' with its local id, the sensor it delegates to, its source file, its mode and why it can or cannot be promoted.',
|
|
127
|
+
' Contract + coverage-evidence only: no TypeScript, no analysis. Exit 0 on a report, 2 if the contract will not load.',
|
|
128
|
+
' ark-check --promote [<ruleId>] [--json] [--apply]',
|
|
129
|
+
' what enforcing would cost: the findings each advisory rule already produces, from ONE run rather than one run per attempt.',
|
|
130
|
+
' Plan by default; --promote <ruleId> --apply (or --promote=<ruleId>) writes mode "enforced" into the ArkRules file that declares it.',
|
|
131
|
+
' A cost the run could not measure (incomplete analysis, classification floor) is named and --apply refuses. Exit 0 / 1 refused / 2 bad args.',
|
|
120
132
|
' ark-check --recommend [--json] [--write-plan] application-shape plan; --write-plan emits ark-adoption-plan.json',
|
|
121
133
|
' ark-check --list-policy-packs enthusiast packs (hexagonal, layered, feature-sliced, monorepo, ui-surface, vertical-slice, ddd-bounded-contexts)',
|
|
122
134
|
' ark-check --apply-policy-pack <id> [--force] write ark.config.json from templates/policy-packs/ (uses preset factory)',
|
|
@@ -9,10 +9,20 @@ import path from 'node:path';
|
|
|
9
9
|
const DEFAULT_TEST_NAME_RE =
|
|
10
10
|
/\.(test|spec)\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$|\/__tests__\/|\/tests?\//i;
|
|
11
11
|
|
|
12
|
-
/**
|
|
13
|
-
const
|
|
12
|
+
/** Default max files to load for coverage evidence (budget). Config: `coverage.maxFiles`. */
|
|
13
|
+
export const DEFAULT_MAX_COVERAGE_FILES = 400;
|
|
14
|
+
/**
|
|
15
|
+
* Hard ceiling on `coverage.maxFiles`. The config validator implements no
|
|
16
|
+
* `maximum` keyword for integers, so a schema bound would be accepted and then
|
|
17
|
+
* silently ignored — the clamp here is the only real enforcement. Retained
|
|
18
|
+
* files are held in memory at up to MAX_FILE_BYTES each, so an unbounded cap is
|
|
19
|
+
* an unbounded heap.
|
|
20
|
+
*/
|
|
21
|
+
export const MAX_COVERAGE_FILES_CAP = 20_000;
|
|
14
22
|
/** Max bytes per file when reading for title/symbol mining. */
|
|
15
23
|
const MAX_FILE_BYTES = 256 * 1024;
|
|
24
|
+
/** Max directory depth for the test walk. Deeper directories are counted, not silent. */
|
|
25
|
+
const MAX_WALK_DEPTH = 8;
|
|
16
26
|
|
|
17
27
|
/**
|
|
18
28
|
* True when absolute is root or a file under root (separator-safe).
|
|
@@ -62,6 +72,30 @@ function matchSimpleGlob(glob, file) {
|
|
|
62
72
|
return new RegExp(`^${out}$`).test(target);
|
|
63
73
|
}
|
|
64
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Coverage scan options carried by ark.config.json (`coverage`).
|
|
77
|
+
* Absent config → `{}`: the built-in heuristic and default budget stay in force.
|
|
78
|
+
* @param {{ coverage?: { testGlobs?: unknown, maxFiles?: unknown, coverageRoots?: unknown } } | null | undefined} config
|
|
79
|
+
* @returns {{ testGlobs?: string[], maxFiles?: number, coverageRoots?: string[] }}
|
|
80
|
+
*/
|
|
81
|
+
export function coverageOptionsFromConfig(config) {
|
|
82
|
+
const coverage = config?.coverage;
|
|
83
|
+
if (!coverage || typeof coverage !== 'object') return {};
|
|
84
|
+
const options = {};
|
|
85
|
+
if (Array.isArray(coverage.testGlobs)) {
|
|
86
|
+
const globs = coverage.testGlobs.filter((g) => typeof g === 'string' && g.length > 0);
|
|
87
|
+
if (globs.length > 0) options.testGlobs = globs;
|
|
88
|
+
}
|
|
89
|
+
if (Number.isInteger(coverage.maxFiles) && coverage.maxFiles > 0) {
|
|
90
|
+
options.maxFiles = Math.min(coverage.maxFiles, MAX_COVERAGE_FILES_CAP);
|
|
91
|
+
}
|
|
92
|
+
if (Array.isArray(coverage.coverageRoots)) {
|
|
93
|
+
const roots = coverage.coverageRoots.filter((r) => typeof r === 'string' && r.length > 0);
|
|
94
|
+
if (roots.length > 0) options.coverageRoots = roots;
|
|
95
|
+
}
|
|
96
|
+
return options;
|
|
97
|
+
}
|
|
98
|
+
|
|
65
99
|
/**
|
|
66
100
|
* Declared invariant ids from an Effective catalog. Empty when the extra is off.
|
|
67
101
|
* @param {{ invariants?: Array<{ id?: unknown }> } | null | undefined} arkRules
|
|
@@ -76,18 +110,65 @@ export function invariantIdsFromCatalog(arkRules) {
|
|
|
76
110
|
/**
|
|
77
111
|
* @param {string} root
|
|
78
112
|
* @param {{ files?: Array<{ path: string }> }} facts
|
|
79
|
-
* @param {{ testGlobs?: string[], invariantIds?: string[] }} [opts]
|
|
113
|
+
* @param {{ testGlobs?: string[], invariantIds?: string[], maxFiles?: number, coverageRoots?: string[] }} [opts]
|
|
80
114
|
* @returns {{
|
|
81
115
|
* fileContents: Record<string, string>,
|
|
82
116
|
* testFiles: string[],
|
|
83
117
|
* testGlobsMissing: boolean,
|
|
84
118
|
* coverageBudgetExhausted: boolean,
|
|
119
|
+
* coverageRoots?: string[],
|
|
120
|
+
* stats: {
|
|
121
|
+
* filesRead: number,
|
|
122
|
+
* filesLoaded: number,
|
|
123
|
+
* testFilesRetained: number,
|
|
124
|
+
* maxFiles: number,
|
|
125
|
+
* discarded: {
|
|
126
|
+
* budget: number,
|
|
127
|
+
* noInvariantMention: number,
|
|
128
|
+
* oversize: number,
|
|
129
|
+
* unreadable: number,
|
|
130
|
+
* depthLimited: number,
|
|
131
|
+
* outOfRoot: number,
|
|
132
|
+
* },
|
|
133
|
+
* },
|
|
85
134
|
* }}
|
|
86
135
|
*/
|
|
87
136
|
export function loadInvariantCoverageInputs(root, facts, opts = {}) {
|
|
88
137
|
const fileContents = {};
|
|
89
138
|
const testFiles = [];
|
|
90
139
|
const seen = new Set();
|
|
140
|
+
// Every path pushFile has already judged, retained or not. `seen` holds only
|
|
141
|
+
// what was retained, so without this the walk roots overlap ('.' contains
|
|
142
|
+
// 'tests' and 'src') and one discarded file is counted — and read — once per
|
|
143
|
+
// overlapping root. The numbers we print must count files, not visits.
|
|
144
|
+
const offered = new Set();
|
|
145
|
+
const maxFiles =
|
|
146
|
+
Number.isInteger(opts.maxFiles) && opts.maxFiles > 0
|
|
147
|
+
? Math.min(opts.maxFiles, MAX_COVERAGE_FILES_CAP)
|
|
148
|
+
: DEFAULT_MAX_COVERAGE_FILES;
|
|
149
|
+
// Reads, not retentions. A test is read before it can be judged for naming an
|
|
150
|
+
// invariant, so the budget bounds what we KEEP, not what we open. Reporting
|
|
151
|
+
// only the retained count made maxFiles look like an I/O knob it is not.
|
|
152
|
+
let filesRead = 0;
|
|
153
|
+
// Every discard is counted. A file dropped without a number is a coverage
|
|
154
|
+
// verdict the user cannot explain.
|
|
155
|
+
const discarded = {
|
|
156
|
+
budget: 0,
|
|
157
|
+
noInvariantMention: 0,
|
|
158
|
+
oversize: 0,
|
|
159
|
+
unreadable: 0,
|
|
160
|
+
depthLimited: 0,
|
|
161
|
+
outOfRoot: 0,
|
|
162
|
+
};
|
|
163
|
+
// Root with every symlink resolved, computed once: the containment test for
|
|
164
|
+
// symlinked candidates compares resolved path to resolved root.
|
|
165
|
+
let realRoot = root;
|
|
166
|
+
try {
|
|
167
|
+
realRoot = fs.realpathSync.native(root);
|
|
168
|
+
} catch {
|
|
169
|
+
// Unresolvable root: fall back to the literal path rather than failing the
|
|
170
|
+
// whole scan. Containment is then as strict as it was before.
|
|
171
|
+
}
|
|
91
172
|
// Declared invariant ids. When present, a test file is RETAINED only if it
|
|
92
173
|
// mentions one: scanning is cheap (hundreds of small files), retaining is
|
|
93
174
|
// what costs memory. Without this the budget goes to whichever N tests the
|
|
@@ -112,31 +193,61 @@ export function loadInvariantCoverageInputs(root, facts, opts = {}) {
|
|
|
112
193
|
const rel = String(relPath || '')
|
|
113
194
|
.replace(/\\/g, '/')
|
|
114
195
|
.replace(/^\.\//, '');
|
|
115
|
-
if (!rel ||
|
|
196
|
+
if (!rel || offered.has(rel)) return;
|
|
197
|
+
offered.add(rel);
|
|
116
198
|
const absolute = path.resolve(root, rel);
|
|
117
199
|
if (!isPathInsideRoot(root, absolute)) return;
|
|
118
200
|
try {
|
|
119
201
|
const stat = fs.statSync(absolute);
|
|
120
|
-
|
|
202
|
+
// Not a file (directory, socket, symlink to a directory): never a
|
|
203
|
+
// coverage candidate, so it is not a discard either.
|
|
204
|
+
if (!stat.isFile()) return;
|
|
205
|
+
// statSync followed the link. A symlink that leaves the root must not
|
|
206
|
+
// become evidence: an out-of-root file naming an invariant would forge
|
|
207
|
+
// coverage for a test that is not in this repo. Compared against the
|
|
208
|
+
// resolved root so a repo living under a symlinked prefix (macOS /tmp)
|
|
209
|
+
// is not mistaken for an escape. Counted, never silent.
|
|
210
|
+
if (!isPathInsideRoot(realRoot, fs.realpathSync.native(absolute))) {
|
|
211
|
+
discarded.outOfRoot += 1;
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (stat.size > MAX_FILE_BYTES) {
|
|
215
|
+
discarded.oversize += 1;
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
// The budget bounds candidates, not visits: a directory or an
|
|
219
|
+
// out-of-root path was never going to be evidence, so counting it as a
|
|
220
|
+
// budget casualty would send the user to raise a cap that was not the
|
|
221
|
+
// reason. Checked here so a file past the cap is never read either.
|
|
222
|
+
if (seen.size >= maxFiles) {
|
|
223
|
+
discarded.budget += 1;
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
121
226
|
const content = fs.readFileSync(absolute, 'utf8');
|
|
227
|
+
filesRead += 1;
|
|
122
228
|
const asTest = forceAsTest || isTestPath(rel);
|
|
123
229
|
// A test that names no invariant is evidence of nothing: scan it, drop
|
|
124
230
|
// it, and let it cost no budget.
|
|
125
|
-
if (asTest && !mentionsInvariant(content))
|
|
231
|
+
if (asTest && !mentionsInvariant(content)) {
|
|
232
|
+
discarded.noInvariantMention += 1;
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
126
235
|
seen.add(rel);
|
|
127
236
|
fileContents[rel] = content;
|
|
128
237
|
if (asTest) testFiles.push(rel);
|
|
129
238
|
} catch {
|
|
130
|
-
//
|
|
239
|
+
// Unreadable (permissions, broken symlink, file moved mid-scan): counted,
|
|
240
|
+
// never dropped in silence.
|
|
241
|
+
discarded.unreadable += 1;
|
|
131
242
|
}
|
|
132
243
|
};
|
|
133
244
|
|
|
134
245
|
// Tests FIRST, then production files.
|
|
135
246
|
//
|
|
136
|
-
// The order is load-bearing, not stylistic. `pushFile` stops at
|
|
137
|
-
//
|
|
138
|
-
// budget — so walking facts first consumed the
|
|
139
|
-
// pushed nothing. Coverage then reported `testGlobsMissing: true`, which the
|
|
247
|
+
// The order is load-bearing, not stylistic. `pushFile` stops at the file
|
|
248
|
+
// budget (`coverage.maxFiles`, default 400), and a real repo has far more
|
|
249
|
+
// production files than the budget — so walking facts first consumed the
|
|
250
|
+
// whole budget and the test walk pushed nothing. Coverage then reported `testGlobsMissing: true`, which the
|
|
140
251
|
// caller renders as "never-had-tests": a claim about the USER's repo that was
|
|
141
252
|
// actually about our own budget. Measured on a 4511-file project: every
|
|
142
253
|
// invariant reported uncovered while its test sat on disk with the invariant
|
|
@@ -145,24 +256,59 @@ export function loadInvariantCoverageInputs(root, facts, opts = {}) {
|
|
|
145
256
|
const testWalkRoots = useCustomGlobs
|
|
146
257
|
? ['.', 'tests', 'test', 'src', '__tests__', 'spec']
|
|
147
258
|
: ['tests', 'test', 'src', '__tests__'];
|
|
259
|
+
// Directories, deduplicated across overlapping walk roots. '.' contains
|
|
260
|
+
// 'tests' and 'src', so the same directory is offered to the walk more than
|
|
261
|
+
// once: counting each visit would report N subtrees where one exists. And a
|
|
262
|
+
// directory refused at depth under one root may be entered from a nearer
|
|
263
|
+
// root, so 'depth-limited' is only the ones NO walk ever entered.
|
|
264
|
+
const dirs = { walked: new Set(), depthLimited: new Set(), unreadable: new Set() };
|
|
148
265
|
for (const dir of testWalkRoots) {
|
|
149
266
|
const absDir = path.join(root, dir === '.' ? '' : dir);
|
|
150
267
|
if (!fs.existsSync(absDir)) continue;
|
|
151
|
-
walkTestFiles(
|
|
152
|
-
|
|
153
|
-
|
|
268
|
+
walkTestFiles(
|
|
269
|
+
absDir,
|
|
270
|
+
root,
|
|
271
|
+
(rel) => {
|
|
272
|
+
if (isTestPath(rel)) pushFile(rel, true);
|
|
273
|
+
},
|
|
274
|
+
0,
|
|
275
|
+
dirs
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
for (const dir of dirs.depthLimited) {
|
|
279
|
+
if (!dirs.walked.has(dir)) discarded.depthLimited += 1;
|
|
280
|
+
}
|
|
281
|
+
for (const dir of dirs.unreadable) {
|
|
282
|
+
if (!dirs.walked.has(dir)) discarded.unreadable += 1;
|
|
154
283
|
}
|
|
155
284
|
|
|
156
285
|
for (const file of facts?.files ?? []) {
|
|
157
286
|
if (file?.path) pushFile(file.path);
|
|
158
287
|
}
|
|
159
288
|
|
|
289
|
+
// Echoed, not applied here: the roots are a declaration Domain compares the
|
|
290
|
+
// scan against. Tooling filtering by them would hide the disagreement that is
|
|
291
|
+
// the whole point of the declaration.
|
|
292
|
+
const coverageRoots = Array.isArray(opts.coverageRoots)
|
|
293
|
+
? opts.coverageRoots.filter((r) => typeof r === 'string' && r.length > 0)
|
|
294
|
+
: [];
|
|
160
295
|
const testGlobsMissing = testFiles.length === 0;
|
|
161
296
|
return {
|
|
162
297
|
fileContents,
|
|
163
298
|
testFiles,
|
|
164
299
|
testGlobsMissing,
|
|
165
|
-
|
|
300
|
+
...(coverageRoots.length > 0 ? { coverageRoots } : {}),
|
|
301
|
+
// Exhausted means the cap actually cost the user a file. Landing exactly
|
|
302
|
+
// on the cap with nothing dropped is a full budget, not an exhausted one:
|
|
303
|
+
// reporting it would tell the user to raise a cap that discarded nothing.
|
|
304
|
+
coverageBudgetExhausted: discarded.budget > 0,
|
|
305
|
+
stats: {
|
|
306
|
+
filesRead,
|
|
307
|
+
filesLoaded: seen.size,
|
|
308
|
+
testFilesRetained: testFiles.length,
|
|
309
|
+
maxFiles,
|
|
310
|
+
discarded,
|
|
311
|
+
},
|
|
166
312
|
};
|
|
167
313
|
}
|
|
168
314
|
|
|
@@ -171,23 +317,33 @@ export function loadInvariantCoverageInputs(root, facts, opts = {}) {
|
|
|
171
317
|
* @param {string} root
|
|
172
318
|
* @param {(rel: string) => void} onFile
|
|
173
319
|
* @param {number} [depth]
|
|
320
|
+
* @param {{ walked: Set<string>, depthLimited: Set<string>, unreadable: Set<string> }} [dirs]
|
|
174
321
|
*/
|
|
175
|
-
function walkTestFiles(dir, root, onFile, depth = 0) {
|
|
176
|
-
if (depth >
|
|
322
|
+
function walkTestFiles(dir, root, onFile, depth = 0, dirs) {
|
|
323
|
+
if (depth > MAX_WALK_DEPTH) {
|
|
324
|
+
// The whole subtree is dropped here. Recorded by path, not counted: a
|
|
325
|
+
// nearer walk root may still reach it within the depth limit.
|
|
326
|
+
if (dirs) dirs.depthLimited.add(dir);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
177
329
|
let entries;
|
|
178
330
|
try {
|
|
179
331
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
180
332
|
} catch {
|
|
333
|
+
if (dirs) dirs.unreadable.add(dir);
|
|
181
334
|
return;
|
|
182
335
|
}
|
|
336
|
+
if (dirs) dirs.walked.add(dir);
|
|
183
337
|
for (const entry of entries) {
|
|
184
338
|
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.git') continue;
|
|
185
339
|
const absolute = path.join(dir, entry.name);
|
|
186
340
|
if (entry.isDirectory()) {
|
|
187
|
-
walkTestFiles(absolute, root, onFile, depth + 1);
|
|
341
|
+
walkTestFiles(absolute, root, onFile, depth + 1, dirs);
|
|
188
342
|
continue;
|
|
189
343
|
}
|
|
190
|
-
|
|
344
|
+
// Symlinks are candidates too: pushFile stats through them, so a broken one
|
|
345
|
+
// is counted as unreadable instead of vanishing from the walk.
|
|
346
|
+
if (!entry.isFile() && !entry.isSymbolicLink()) continue;
|
|
191
347
|
const rel = path.relative(root, absolute).replace(/\\/g, '/');
|
|
192
348
|
onFile(rel);
|
|
193
349
|
}
|