arkgate 4.8.8 → 4.8.10
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 +141 -3
- package/README.md +9 -6
- package/bin/ark-check-runtime.mjs +24 -2
- package/bin/ark-layer-match.mjs +25 -10
- package/bin/ark.mjs +18 -10
- package/bin/lib/agent-homes.mjs +1 -1
- package/bin/lib/analysis-engine.mjs +8 -8
- package/bin/lib/architecture-scan.mjs +91 -4
- package/bin/lib/ark-order-facts.mjs +11 -4
- package/bin/lib/ark-order-sensors.mjs +103 -3
- package/bin/lib/arkrule-file-hints.mjs +255 -20
- package/bin/lib/arkrules-sensors.mjs +364 -68
- package/bin/lib/baseline-key.mjs +45 -1
- package/bin/lib/config-contract.mjs +9 -3
- package/bin/lib/config-extras.mjs +1 -0
- package/bin/lib/contract-smells.mjs +12 -6
- package/bin/lib/diagnostic-catalog.mjs +1 -0
- package/bin/lib/doctor-human.mjs +35 -10
- package/bin/lib/doctor-next-actions.mjs +24 -3
- package/bin/lib/field-install.mjs +23 -2
- package/bin/lib/first-run-help.mjs +69 -5
- package/bin/lib/gate-files.mjs +108 -22
- package/bin/lib/managed-upgrade.mjs +9 -1
- package/bin/lib/resolved-candidate-facts.mjs +82 -1
- package/bin/lib/rules-inventory.mjs +7 -3
- package/bin/lib/upgrade-command.mjs +17 -4
- package/bin/lib/upstream-report.mjs +330 -0
- package/bin/lib/violations.mjs +51 -15
- package/dist/{configTypes-0eHpocR3.d.ts → configTypes-j7so8B4O.d.ts} +12 -0
- package/dist/{diagnosticCatalog-DxKCTBbp.d.ts → diagnosticCatalog-biferT4R.d.ts} +11 -5
- package/dist/eslint/index.cjs +5 -8
- package/dist/eslint/index.d.ts +6 -4
- package/dist/eslint/index.js +5 -8
- package/dist/index.cjs +30 -33
- package/dist/index.d.ts +18 -6
- package/dist/index.js +30 -33
- package/dist/nestjs/index.cjs +3 -3
- package/dist/nestjs/index.d.ts +3 -3
- package/dist/nestjs/index.js +2 -2
- package/dist/order/index.cjs +1 -1
- package/dist/order/index.d.ts +6 -2
- package/dist/order/index.js +1 -1
- package/dist/runtime/index.cjs +11 -11
- package/dist/runtime/index.d.ts +6 -6
- package/dist/runtime/index.js +11 -11
- package/dist/{types-BK47clMl.d.ts → types-Djbs3KjE.d.ts} +1 -1
- package/dist/{types-DxvmJO-D.d.ts → types-tGhZUiGX.d.ts} +1 -1
- package/docs/README.md +4 -3
- package/docs/agent-guide.md +27 -2
- package/docs/ai-gates.md +8 -0
- package/docs/arkorder.md +30 -7
- package/docs/brownfield-adoption.md +30 -0
- package/docs/configuration.md +61 -14
- package/docs/develop.md +4 -2
- package/docs/diagnostics.md +10 -0
- package/docs/package-surface.md +7 -5
- package/docs/use.md +11 -0
- package/package.json +1 -1
- package/schemas/ark.config.schema.json +12 -2
- package/server.json +2 -2
- package/templates/agent-skills/README.md +1 -1
- package/templates/agent-skills/ark-contract/SKILL.md +1 -1
- package/templates/agent-skills/ark-explore/SKILL.md +24 -3
- package/templates/skills/ark-contract.md +1 -1
- package/templates/skills/ark-explore.md +24 -3
|
@@ -41,7 +41,7 @@ export const CONTRACT_SMELL_OUTCOMES = Object.freeze({
|
|
|
41
41
|
'contract-lateral-adapter-allow':
|
|
42
42
|
'One adapter layer may import another adapter family directly — shared mappers/aliases will pile up in the wrong place. Move shared shapes into Domain (or a shared kernel) instead of adapter-to-adapter reach.',
|
|
43
43
|
'contract-dead-rule':
|
|
44
|
-
'A rule enforces nothing: it points at a layer that matches no files or does not exist, or
|
|
44
|
+
'A rule enforces nothing: it points at a layer that matches no files or does not exist, or is a same-layer allow (classic same-layer deny is also a no-op). A same-layer peerIsolation deny is a live wall, not a dead rule.',
|
|
45
45
|
});
|
|
46
46
|
|
|
47
47
|
export const CONTRACT_SMELL_ACKS_PATH = '.ark/contract-smell-acks.json';
|
|
@@ -277,14 +277,20 @@ export function analyzeContractSmells(
|
|
|
277
277
|
}
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
-
// 4) Dead rules: self
|
|
281
|
-
// or — when coverage is known — layers matching
|
|
280
|
+
// 4) Dead rules: self-allow and classic same-layer deny (the gate ignores
|
|
281
|
+
// those), unknown layers, or — when coverage is known — layers matching
|
|
282
|
+
// zero files (optional layers exempt). `peerIsolation: true` + `allowed:
|
|
283
|
+
// false` is a live self-edge wall, not a dead rule.
|
|
282
284
|
for (const r of rules) {
|
|
283
285
|
const edge = `${r.from}->${r.to}`;
|
|
284
286
|
const ackEdge = ackMatchable(r.from, r.to) ? edge : null;
|
|
285
287
|
if (r.from === r.to) {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
+
const livePeerIsolationWall = r.peerIsolation === true && r.allowed === false;
|
|
289
|
+
if (!livePeerIsolationWall) {
|
|
290
|
+
add('contract-dead-rule', ackEdge, `rule:${edge} (self edge has no effect)`);
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
// Live slice wall on a self-edge — still check unknown/empty layers.
|
|
288
294
|
}
|
|
289
295
|
for (const side of [r.from, r.to]) {
|
|
290
296
|
if (side.length === 0) continue;
|
|
@@ -409,7 +415,7 @@ function fixFor(id) {
|
|
|
409
415
|
case 'contract-lateral-adapter-allow':
|
|
410
416
|
return `Move shared shapes into Domain/shared kernel and drop the lateral allow (/ark-adopt), or acknowledge with a reason in ${CONTRACT_SMELL_ACKS_PATH}.`;
|
|
411
417
|
case 'contract-dead-rule':
|
|
412
|
-
return 'Fix the layer patterns so the layer matches real files, or delete
|
|
418
|
+
return 'Fix the layer patterns so the layer matches real files, or delete a stale self-allow / classic self-deny via /ark-adopt. Never delete a live peerIsolation wall (`peerIsolation: true` with `allowed: false`).';
|
|
413
419
|
default:
|
|
414
420
|
return 'Review the contract edge via /ark-adopt; never weaken the gate to silence a smell.';
|
|
415
421
|
}
|
|
@@ -55,6 +55,7 @@ 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('ARKRULE_HINT_BUDGET_EXHAUSTED', 'arkrules', 'Structural-hint budget exhausted', 'orchestration-only, thin-adapter, and writes-via-aggregate only evaluate files the hint loader preloaded. When eligible governed files exceed that budget (coverage.maxFiles, default 400 — there is no arkrules.hintBudget), those sensors never saw the rest of their scope. Enforced + unreviewed is not green. The finding names exact hinted/governed counts and per-sensor reviewed N/M of scope.', 'Raise coverage.maxFiles in ark.config.json (this cap also bounds structural-hint preload; --doctor names the coupling) so hinted/governed counts match, then re-run with --strict-config. An enforced hint sensor that cannot see its scope fails strict.'),
|
|
58
59
|
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
60
|
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 }),
|
|
60
61
|
// ── ArkRun (opt-in extra; RN05 dual-depth nextAction) ────────────────────
|
package/bin/lib/doctor-human.mjs
CHANGED
|
@@ -13,6 +13,23 @@ import { enforcementDoctorLines } from './enforcement-state.mjs';
|
|
|
13
13
|
import { analysisIncompleteStatement } from './analysis-completeness.mjs';
|
|
14
14
|
import { skillGapsForActiveHost, detectCodexHomeGap, codexConcernIsActive } from './agent-gates.mjs';
|
|
15
15
|
import { agentHomeConcernIsActive } from './agent-homes.mjs';
|
|
16
|
+
import { REQUIRED_GATE_WORKFLOW } from './gate-files.mjs';
|
|
17
|
+
|
|
18
|
+
function displayedMissingGates(gatesMissing, view) {
|
|
19
|
+
const list = Array.isArray(gatesMissing) ? gatesMissing : [];
|
|
20
|
+
const hideGlob = Boolean(
|
|
21
|
+
view?.ciNotFailClosed?.workflowFile ||
|
|
22
|
+
view?.ciNotFailClosed?.error === 'ci-not-fail-closed' ||
|
|
23
|
+
view?.ciMergeBoundary?.ci?.workflowPresent
|
|
24
|
+
);
|
|
25
|
+
return hideGlob ? list.filter((item) => item !== REQUIRED_GATE_WORKFLOW) : list;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function ciNotFailClosedNotice(view) {
|
|
29
|
+
const file = view?.ciNotFailClosed?.workflowFile;
|
|
30
|
+
if (!file) return null;
|
|
31
|
+
return `CI not fail-closed: ${file} — remove the skippable if:, or write .ark/adoption-stance.json with stance: advisory-only`;
|
|
32
|
+
}
|
|
16
33
|
|
|
17
34
|
function lineWith(ok, warn, bad, color) {
|
|
18
35
|
return (mark, text) => console.log(` ${mark} ${text}`);
|
|
@@ -52,6 +69,8 @@ export function printDoctorCompactHuman(view) {
|
|
|
52
69
|
gatesMissing,
|
|
53
70
|
violations,
|
|
54
71
|
} = view;
|
|
72
|
+
const listedMissing = displayedMissingGates(gatesMissing, view);
|
|
73
|
+
const skippableCi = ciNotFailClosedNotice(view);
|
|
55
74
|
|
|
56
75
|
console.log(color.bold(`Ark doctor — ${path.basename(path.resolve(root)) || '.'}`));
|
|
57
76
|
if (!analysisComplete) line(warn, analysisIncompleteStatement(completeness));
|
|
@@ -123,15 +142,17 @@ export function printDoctorCompactHuman(view) {
|
|
|
123
142
|
}
|
|
124
143
|
|
|
125
144
|
const hostRed =
|
|
126
|
-
|
|
145
|
+
listedMissing.length > 0 ||
|
|
127
146
|
Boolean(writePath.gap) ||
|
|
128
|
-
writePathHonesty?.softWriteHost === true
|
|
147
|
+
writePathHonesty?.softWriteHost === true ||
|
|
148
|
+
Boolean(skippableCi);
|
|
129
149
|
if (hostRed) {
|
|
130
150
|
console.log('');
|
|
131
151
|
console.log(color.bold('Host / CI'));
|
|
132
152
|
if (writePath.activeHost) line(' ', `Active host: ${writePath.activeHost}`);
|
|
133
|
-
if (
|
|
134
|
-
|
|
153
|
+
if (listedMissing.length > 0) line(bad, `Missing gates: ${listedMissing.join(', ')}`);
|
|
154
|
+
if (skippableCi) line(warn, skippableCi);
|
|
155
|
+
else if (listedMissing.length === 0 && (writePath.gap || writePathHonesty?.softWriteHost)) {
|
|
135
156
|
line(warn, 'Local writes are advisory; required CI is the merge boundary.');
|
|
136
157
|
}
|
|
137
158
|
}
|
|
@@ -212,6 +233,8 @@ export function printDoctorDetailsHuman(view) {
|
|
|
212
233
|
staleRunners,
|
|
213
234
|
adoption,
|
|
214
235
|
} = view;
|
|
236
|
+
const listedMissing = displayedMissingGates(gatesMissing, view);
|
|
237
|
+
const skippableCi = ciNotFailClosedNotice(view);
|
|
215
238
|
const modeTitle = operatingModeTitle(operatingMode, designFitness.designWeak, stewardUnfinished);
|
|
216
239
|
|
|
217
240
|
console.log('');
|
|
@@ -336,7 +359,7 @@ export function printDoctorDetailsHuman(view) {
|
|
|
336
359
|
`${violations.length} total${typeNote}${supNote}${activeCount > 0 ? ` — ${activeCount} NOT baselined` : ''}`
|
|
337
360
|
);
|
|
338
361
|
for (const edge of summary.edges.slice(0, 3)) line(' ', color.dim(`${edge.count} ${edge.edge}`));
|
|
339
|
-
if (summary.concentrated) {
|
|
362
|
+
if (summary.concentrated && typeof summary.dominant === 'string' && summary.dominant.includes(' → ')) {
|
|
340
363
|
line(warn, color.dim(`${Math.round(summary.dominantShare * 100)}% on one edge (${summary.dominant}) — likely a contract fix, not debt`));
|
|
341
364
|
}
|
|
342
365
|
}
|
|
@@ -385,9 +408,11 @@ export function printDoctorDetailsHuman(view) {
|
|
|
385
408
|
|
|
386
409
|
console.log('');
|
|
387
410
|
console.log(color.bold('Gates & skills'));
|
|
388
|
-
if (
|
|
389
|
-
|
|
390
|
-
|
|
411
|
+
if (listedMissing.length === 0 && !skippableCi) {
|
|
412
|
+
line(ok, 'Shared gate artifacts found on disk (AGENTS.md, .mcp.json, CI); runtime activation is reported separately');
|
|
413
|
+
} else {
|
|
414
|
+
if (listedMissing.length > 0) line(bad, `Missing gates: ${listedMissing.join(', ')}`);
|
|
415
|
+
if (skippableCi) line(warn, skippableCi);
|
|
391
416
|
}
|
|
392
417
|
const humanSkillGaps = skillGapsForActiveHost(skillGaps);
|
|
393
418
|
const legacyCodex = humanSkillGaps.some((g) => g.tool === 'codex' && g.legacyPromptsOnly);
|
|
@@ -449,7 +474,7 @@ export function printDoctorDetailsHuman(view) {
|
|
|
449
474
|
console.log('');
|
|
450
475
|
console.log(color.bold('Baseline'));
|
|
451
476
|
if (!baseline.exists) {
|
|
452
|
-
line(!analysisComplete || violations.length > 0 ? warn : ok, !analysisComplete ? 'No baseline — current violations were not fully evaluated' : violations.length > 0 ? 'No baseline — adopting a dirty repo? freeze with --update-baseline' : 'No baseline (nothing to freeze)');
|
|
477
|
+
line(!analysisComplete || violations.length > 0 ? warn : ok, !analysisComplete ? 'No baseline — current violations were not fully evaluated' : violations.length > 0 ? 'No baseline — adopting a dirty repo? freeze with --update-baseline --force --contract-session --author <steward>' : 'No baseline (nothing to freeze)');
|
|
453
478
|
} else {
|
|
454
479
|
const baseMark = !analysisComplete || baselineHonesty.dirtyBaselineRisk ? warn : ok;
|
|
455
480
|
line(baseMark, `${baseline.keys.size} frozen key(s)${analysisComplete ? '' : ' — stale comparison not verified'}`);
|
|
@@ -457,7 +482,7 @@ export function printDoctorDetailsHuman(view) {
|
|
|
457
482
|
line(warn, baselineHonesty.message);
|
|
458
483
|
}
|
|
459
484
|
if (analysisComplete && staleBaseline > 0) {
|
|
460
|
-
line(warn, `${staleBaseline} stale entr(y/ies) no longer occur — tighten with --update-baseline
|
|
485
|
+
line(warn, `${staleBaseline} stale entr(y/ies) no longer occur — tighten with --update-baseline --force --contract-session --author <steward>`);
|
|
461
486
|
}
|
|
462
487
|
}
|
|
463
488
|
|
|
@@ -7,10 +7,20 @@ import { skillGapsForActiveHost } from './agent-gates.mjs';
|
|
|
7
7
|
import { agentHomeConcernIsActive, agentHomeRefreshCommand } from './agent-homes.mjs';
|
|
8
8
|
import { mergePostGreenTopActions } from './post-green-path.mjs';
|
|
9
9
|
import { ADOPTED_NOT, NOT_ADOPTED_NEXT_ACTION } from './adoption-stance.mjs';
|
|
10
|
+
import { REQUIRED_GATE_WORKFLOW } from './gate-files.mjs';
|
|
11
|
+
|
|
12
|
+
function missingGateFiles(ctx) {
|
|
13
|
+
const list = Array.isArray(ctx.gatesMissing) ? ctx.gatesMissing : [];
|
|
14
|
+
if (ctx.ciNotFailClosed) {
|
|
15
|
+
return list.filter((item) => item !== REQUIRED_GATE_WORKFLOW);
|
|
16
|
+
}
|
|
17
|
+
return list;
|
|
18
|
+
}
|
|
10
19
|
|
|
11
20
|
export function collectDoctorNextActions(ctx) {
|
|
12
21
|
const actions = [];
|
|
13
|
-
const
|
|
22
|
+
const missingFiles = missingGateFiles(ctx);
|
|
23
|
+
const gatesInstalled = missingFiles.length === 0;
|
|
14
24
|
const planAEmpty = !ctx.activeCount;
|
|
15
25
|
const notAdopted = ctx.adopted !== 'required-merge' && ctx.adopted !== 'advisory-only-acked';
|
|
16
26
|
if (notAdopted || ctx.adopted === ADOPTED_NOT || ctx.adopted == null) {
|
|
@@ -55,9 +65,18 @@ export function collectDoctorNextActions(ctx) {
|
|
|
55
65
|
);
|
|
56
66
|
}
|
|
57
67
|
if (ctx.writePath?.gap?.fix && !gatesInstalled) actions.push(ctx.writePath.gap.fix);
|
|
58
|
-
if (!gatesInstalled &&
|
|
68
|
+
if (!gatesInstalled && missingFiles.length > 0) {
|
|
59
69
|
actions.push(`install gates (${arkCommand(ctx.root, 'ark-check', '--install-agent-gates')})`);
|
|
60
70
|
}
|
|
71
|
+
if (ctx.ciNotFailClosed) {
|
|
72
|
+
const file = ctx.ciNotFailClosed.workflowFile;
|
|
73
|
+
actions.push(
|
|
74
|
+
ctx.ciNotFailClosed.nextAction ||
|
|
75
|
+
(file
|
|
76
|
+
? `Remove the skippable if: in ${file}, or write .ark/adoption-stance.json with stance: advisory-only`
|
|
77
|
+
: 'Remove the skippable if:, or write .ark/adoption-stance.json with stance: advisory-only')
|
|
78
|
+
);
|
|
79
|
+
}
|
|
61
80
|
const humanSkillGaps = skillGapsForActiveHost(ctx.skillGaps);
|
|
62
81
|
const legacyCodex = humanSkillGaps.some((g) => g.tool === 'codex' && g.legacyPromptsOnly);
|
|
63
82
|
const remainingGaps = humanSkillGaps.filter(
|
|
@@ -97,7 +116,9 @@ export function collectDoctorNextActions(ctx) {
|
|
|
97
116
|
actions.push('review dirty baseline freezes — fix the contract before trusting green-via-freeze');
|
|
98
117
|
}
|
|
99
118
|
if (ctx.analysisComplete && ctx.staleBaseline > 0) {
|
|
100
|
-
actions.push(
|
|
119
|
+
actions.push(
|
|
120
|
+
'tighten the baseline (--update-baseline --force --contract-session --author <steward>)'
|
|
121
|
+
);
|
|
101
122
|
}
|
|
102
123
|
if (ctx.staleRunners.length > 0) {
|
|
103
124
|
actions.push(
|
|
@@ -83,6 +83,27 @@ function addDevDependencyPreservingFormat(source, version) {
|
|
|
83
83
|
return `${source.slice(0, contentEnd)}${addition}${eol}${rootClosingIndent}${source.slice(rootClose)}`;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
const ARK_CHECK_BIN_RE = /\b(?:ark-check|arkgate-check)(?:\.mjs|\.js)?\b/;
|
|
87
|
+
const ARK_CHECK_RUNNER_RE =
|
|
88
|
+
/(?:^|[\s"'`;|&])(?:npx|pnpm|yarn|npm|bunx?|node)(?:\s|$)/;
|
|
89
|
+
const GITHUB_RUN_KEY_RE = /^\s*(?:-\s+)?run:\s+/;
|
|
90
|
+
const YAML_CHECK_JOB_ID_RE =
|
|
91
|
+
/^\s*(?:-\s+)?['"]?(?:ark-check|arkgate-check)['"]?\s*:/;
|
|
92
|
+
const YAML_CONCURRENCY_GROUP_RE = /^\s*group:\s+/;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* True when the line invokes ark-check / arkgate-check (npx/pnpm/yarn/npm/node/run).
|
|
96
|
+
* YAML concurrency.group and job-id keys that only contain the name are not invocations.
|
|
97
|
+
*/
|
|
98
|
+
export function isArkCheckInvocationLine(command) {
|
|
99
|
+
if (typeof command !== 'string' || !command.trim()) return false;
|
|
100
|
+
if (/^\s*#/.test(command)) return false;
|
|
101
|
+
if (YAML_CHECK_JOB_ID_RE.test(command)) return false;
|
|
102
|
+
if (YAML_CONCURRENCY_GROUP_RE.test(command)) return false;
|
|
103
|
+
if (!ARK_CHECK_BIN_RE.test(command)) return false;
|
|
104
|
+
return ARK_CHECK_RUNNER_RE.test(command) || GITHUB_RUN_KEY_RE.test(command);
|
|
105
|
+
}
|
|
106
|
+
|
|
86
107
|
/**
|
|
87
108
|
* Ensure a check command string includes `--baseline <file>`.
|
|
88
109
|
* Only touches strings that already invoke ark-check / arkgate-check.
|
|
@@ -97,7 +118,7 @@ export function ensureBaselineFlagInCheckCommand(
|
|
|
97
118
|
if (/^\s*#/.test(command)) {
|
|
98
119
|
return { command, changed: false };
|
|
99
120
|
}
|
|
100
|
-
if (
|
|
121
|
+
if (!isArkCheckInvocationLine(command)) {
|
|
101
122
|
return { command, changed: false };
|
|
102
123
|
}
|
|
103
124
|
if (/(?:^|\s)--baseline(?:\s|=|$)/.test(command)) {
|
|
@@ -180,7 +201,7 @@ export function syncBaselineIntoCheckSurfaces(root, opts = {}) {
|
|
|
180
201
|
let fileChanged = false;
|
|
181
202
|
const nextLines = lines.map((line) => {
|
|
182
203
|
if (/^\s*#/.test(line)) return line;
|
|
183
|
-
if (
|
|
204
|
+
if (!isArkCheckInvocationLine(line)) return line;
|
|
184
205
|
if (/(?:^|\s)--baseline(?:\s|=|$)/.test(line)) return line;
|
|
185
206
|
const { command, changed: c } = ensureBaselineFlagInCheckCommand(line, flagRel);
|
|
186
207
|
if (c) {
|
|
@@ -95,12 +95,74 @@ Non-interactive (no TTY): uses the same defaults as --yes — never calls readli
|
|
|
95
95
|
`;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/** `--sensors` is contract + coverage-evidence only — never a full-check pass. */
|
|
99
|
+
export const SENSORS_PARTIAL_MODE_LINE =
|
|
100
|
+
'Contract + coverage-evidence only: no TypeScript, no analysis. Not a validity verdict.';
|
|
101
|
+
|
|
102
|
+
export const SENSORS_DID_NOT_RUN = Object.freeze(['TypeScript', 'analysis']);
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Stamp a successful `--sensors --json` payload so agents cannot read exit 0 as
|
|
106
|
+
* a full-check pass. Failure payloads (`sensors.ok === false`) stay untouched.
|
|
107
|
+
*/
|
|
108
|
+
export function stampSensorsPartialModePayload(payload) {
|
|
109
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return payload;
|
|
110
|
+
const sensors = payload.sensors;
|
|
111
|
+
if (!sensors || typeof sensors !== 'object' || Array.isArray(sensors)) return payload;
|
|
112
|
+
if (sensors.ok === false) return payload;
|
|
113
|
+
return {
|
|
114
|
+
...payload,
|
|
115
|
+
sensors: {
|
|
116
|
+
...sensors,
|
|
117
|
+
notAVerdict: true,
|
|
118
|
+
didNotRun: [...SENSORS_DID_NOT_RUN],
|
|
119
|
+
partialMode: 'contract-only',
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* After `runSensors`, name what this mode skipped. Human success prints the
|
|
126
|
+
* line on stdout; JSON success restamps the captured object. Failures reprint
|
|
127
|
+
* as-is so exit 2 does not look like a map.
|
|
128
|
+
*/
|
|
129
|
+
export async function withSensorsPartialModeHonesty(args, run) {
|
|
130
|
+
if (args?.json) {
|
|
131
|
+
const chunks = [];
|
|
132
|
+
const original = console.log;
|
|
133
|
+
console.log = (...parts) => {
|
|
134
|
+
chunks.push(parts.map(String).join(' '));
|
|
135
|
+
};
|
|
136
|
+
try {
|
|
137
|
+
await run();
|
|
138
|
+
} finally {
|
|
139
|
+
console.log = original;
|
|
140
|
+
}
|
|
141
|
+
const text = chunks.join('\n');
|
|
142
|
+
if ((process.exitCode ?? 0) !== 0) {
|
|
143
|
+
if (text) original(text);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
original(JSON.stringify(stampSensorsPartialModePayload(JSON.parse(text)), null, 2));
|
|
148
|
+
} catch {
|
|
149
|
+
original(text);
|
|
150
|
+
}
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
await run();
|
|
154
|
+
if ((process.exitCode ?? 0) === 0) {
|
|
155
|
+
console.log(SENSORS_PARTIAL_MODE_LINE);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
98
159
|
export function checkUsage() {
|
|
99
160
|
return [
|
|
100
161
|
'arkgate-check (alias ark-check) — the architecture check.',
|
|
101
162
|
'',
|
|
102
163
|
' arkgate-check --doctor where you are: one status light, one next action',
|
|
103
164
|
' arkgate-check --strict-merge CI / merge gate (required GitHub status)',
|
|
165
|
+
' arkgate-check --sensors which sensors can ever be enforced (does not run analysis)',
|
|
104
166
|
'',
|
|
105
167
|
'Every flag and command: arkgate-check --help --all',
|
|
106
168
|
].join('\n');
|
|
@@ -124,7 +186,7 @@ export function checkUsageAll() {
|
|
|
124
186
|
' Exit 0 ran and clean, 1 drift remains, 2 could not run (no usable base ref).',
|
|
125
187
|
' ark-check --sensors [--json] every sensor with its tier and whether it can EVER be enforced, plus every declared rule',
|
|
126
188
|
' with its local id, the sensor it delegates to, its source file, its mode and why it can or cannot be promoted.',
|
|
127
|
-
|
|
189
|
+
` ${SENSORS_PARTIAL_MODE_LINE} Exit 0 on a report, 2 if the contract will not load.`,
|
|
128
190
|
' ark-check --promote [<ruleId>] [--json] [--apply]',
|
|
129
191
|
' what enforcing would cost: the findings each advisory rule already produces, from ONE run rather than one run per attempt.',
|
|
130
192
|
' Plan by default; --promote <ruleId> --apply (or --promote=<ruleId>) writes mode "enforced" into the ArkRules file that declares it.',
|
|
@@ -143,12 +205,14 @@ export function checkUsageAll() {
|
|
|
143
205
|
' ark-check --init [--preset hexagonal|layered|feature-sliced|monorepo|ui-surface|vertical-slice|ddd-bounded-contexts|vite-vercel-spa|clean-architecture|onion-architecture] [--force] [--follow-config-root]',
|
|
144
206
|
' --follow-config-root On writes (init/install-agent-gates/migrate --write/…), adopt walked-up monorepo config root (default: keep explicit --root)',
|
|
145
207
|
' ark-check --install-agent-gates [--tools claude,cursor,codex,grok,antigravity] [--require-write-hook <host>] [--skills-only] [--codex-home] [--claude-home] [--grok-home] [--antigravity-home] [--agent-homes] [--force]',
|
|
146
|
-
' ark-check --update-baseline [file]
|
|
208
|
+
' ark-check --update-baseline [file] --force --contract-session --author <steward>',
|
|
209
|
+
' freeze current violations (default .ark-baseline.json). --contract-session is required;',
|
|
210
|
+
' --force when freeze-refuse fires; --author when stewards[] is set.',
|
|
147
211
|
' ark-check --print-config eleven-layer',
|
|
148
212
|
'',
|
|
149
|
-
'Adopting Ark in an existing codebase? Run --update-baseline
|
|
150
|
-
'violations, commit the baseline file, and gate CI with --baseline: only NEW
|
|
151
|
-
'fail the check, so the ratchet only moves toward zero.',
|
|
213
|
+
'Adopting Ark in an existing codebase? Run --update-baseline --force --contract-session --author <steward>',
|
|
214
|
+
'once to freeze existing violations, commit the baseline file, and gate CI with --baseline: only NEW',
|
|
215
|
+
'violations fail the check, so the ratchet only moves toward zero.',
|
|
152
216
|
'',
|
|
153
217
|
'Team parliament: law files (ark.config / arkrules / .ark-baseline.json) cannot ship in',
|
|
154
218
|
'the same diff as product source. --changed --base <ref> checks touched files only.',
|
package/bin/lib/gate-files.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import fs from 'node:fs';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { codexProjectMcpIsValid } from './codex-home.mjs';
|
|
8
|
-
import { enforcingArkRunText } from './github-enforcement.mjs';
|
|
8
|
+
import { enforcingArkRunText, runsArkCheck } from './github-enforcement.mjs';
|
|
9
9
|
|
|
10
10
|
export const __packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
11
11
|
export const __arkCheckCli = path.join(__packageRoot, 'bin', 'ark-check.mjs');
|
|
@@ -120,7 +120,8 @@ export const REQUIRED_GATE_FILES = [
|
|
|
120
120
|
'AGENTS.md',
|
|
121
121
|
'.mcp.json',
|
|
122
122
|
];
|
|
123
|
-
const REQUIRED_GATE_WORKFLOW = '.github/workflows/*.yml running ark-check';
|
|
123
|
+
export const REQUIRED_GATE_WORKFLOW = '.github/workflows/*.yml running ark-check';
|
|
124
|
+
export const CI_NOT_FAIL_CLOSED_ERROR = 'ci-not-fail-closed';
|
|
124
125
|
const COMPACT_ROUTER = /<!--\s*arkgate:compact-router host=([a-z]+)\s*-->/;
|
|
125
126
|
const FAIL_CLOSED_ARK_FLAG = /(?:^|\s)--(?:strict|strict-merge|require-gates)(?=\s|$)/;
|
|
126
127
|
|
|
@@ -584,27 +585,112 @@ function withVerifiedDependencyJobs(content) {
|
|
|
584
585
|
return lines.join('\n');
|
|
585
586
|
}
|
|
586
587
|
|
|
587
|
-
|
|
588
|
+
function isGuaranteedJobCondition(value) {
|
|
589
|
+
const text = unquoteYamlScalar(value);
|
|
590
|
+
return (
|
|
591
|
+
/^(?:\$\{\{\s*)?always\(\)(?:\s*\}\})?$/i.test(text) ||
|
|
592
|
+
/^(?:true|\$\{\{\s*true\s*\}\})$/i.test(text)
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function neutralizeSkippableJobControls(content) {
|
|
597
|
+
const { lines, jobs } = workflowJobSections(content);
|
|
598
|
+
for (const job of jobs) {
|
|
599
|
+
const condition = jobProperty(lines, job, 'if');
|
|
600
|
+
if (condition && !isGuaranteedJobCondition(condition.value)) {
|
|
601
|
+
lines[condition.index] = lines[condition.index].replace(
|
|
602
|
+
/^(\s*(?:"if"|'if'|if):\s*).*$/i,
|
|
603
|
+
'$1true'
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
const continuation = jobProperty(lines, job, 'continue-on-error');
|
|
607
|
+
if (continuation && !/^['"]?false['"]?$/i.test(unquoteYamlScalar(continuation.value))) {
|
|
608
|
+
lines[continuation.index] = lines[continuation.index].replace(
|
|
609
|
+
/^(\s*(?:"continue-on-error"|'continue-on-error'|continue-on-error):\s*).*$/i,
|
|
610
|
+
'$1false'
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
return lines.join('\n');
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function workflowMentionsArkAction(content) {
|
|
618
|
+
return /^\s*(?:-\s+)?uses:\s*['"]?pedroknigge\/arkgate@/im.test(String(content));
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function listWorkflowYamlFiles(root) {
|
|
588
622
|
const workflowsDir = path.join(root, '.github', 'workflows');
|
|
589
|
-
if (!fs.existsSync(workflowsDir)) return
|
|
623
|
+
if (!fs.existsSync(workflowsDir)) return [];
|
|
624
|
+
try {
|
|
625
|
+
return fs.readdirSync(workflowsDir).filter((file) => /\.ya?ml$/i.test(file));
|
|
626
|
+
} catch {
|
|
627
|
+
return [];
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Presence vs fail-closed. A skippable `if:` still means the YAML exists.
|
|
633
|
+
* `failClosed` matches `hasArkWorkflow` (merge line). `present` is any
|
|
634
|
+
* ark-check / arkgate action workflow, including draft-skip jobs.
|
|
635
|
+
*/
|
|
636
|
+
export function inspectArkCiGate(root) {
|
|
637
|
+
const failClosedFiles = [];
|
|
638
|
+
const presentFiles = [];
|
|
590
639
|
const declaredScript = architectureScript(root);
|
|
591
|
-
const
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
640
|
+
const failClosedScript = isFailClosedArchitectureScript(declaredScript)
|
|
641
|
+
? declaredScript
|
|
642
|
+
: '';
|
|
643
|
+
const workflowsDir = path.join(root, '.github', 'workflows');
|
|
644
|
+
for (const file of listWorkflowYamlFiles(root)) {
|
|
645
|
+
let content = '';
|
|
646
|
+
try {
|
|
647
|
+
content = fs.readFileSync(path.join(workflowsDir, file), 'utf8');
|
|
648
|
+
} catch {
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
const prepared = withVerifiedDependencyJobs(withFailClosedArkActions(content));
|
|
652
|
+
const failClosed = FAIL_CLOSED_ARK_FLAG.test(
|
|
653
|
+
enforcingArkRunText(prepared, failClosedScript)
|
|
654
|
+
);
|
|
655
|
+
const visible = withVerifiedDependencyJobs(
|
|
656
|
+
neutralizeSkippableJobControls(withFailClosedArkActions(content))
|
|
657
|
+
);
|
|
658
|
+
const present =
|
|
659
|
+
failClosed ||
|
|
660
|
+
runsArkCheck(visible, declaredScript) ||
|
|
661
|
+
workflowMentionsArkAction(content);
|
|
662
|
+
const relativePath = `.github/workflows/${file}`;
|
|
663
|
+
if (failClosed) failClosedFiles.push(relativePath);
|
|
664
|
+
if (present) presentFiles.push(relativePath);
|
|
665
|
+
}
|
|
666
|
+
return {
|
|
667
|
+
failClosed: failClosedFiles.length > 0,
|
|
668
|
+
present: presentFiles.length > 0,
|
|
669
|
+
failClosedFiles,
|
|
670
|
+
presentFiles,
|
|
671
|
+
workflowFile:
|
|
672
|
+
presentFiles.find((file) => !failClosedFiles.includes(file)) ??
|
|
673
|
+
presentFiles[0] ??
|
|
674
|
+
null,
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
export function hasArkWorkflow(root) {
|
|
679
|
+
return inspectArkCiGate(root).failClosed;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
export function ciNotFailClosed(root) {
|
|
683
|
+
const ci = inspectArkCiGate(root);
|
|
684
|
+
if (!ci.present || ci.failClosed) return null;
|
|
685
|
+
const workflowFile = ci.workflowFile;
|
|
686
|
+
const named = workflowFile || REQUIRED_GATE_WORKFLOW;
|
|
687
|
+
return {
|
|
688
|
+
error: CI_NOT_FAIL_CLOSED_ERROR,
|
|
689
|
+
workflowFile,
|
|
690
|
+
workflowFiles: ci.presentFiles,
|
|
691
|
+
nextAction: `Remove the skippable if: in ${named}, or write .ark/adoption-stance.json with stance: advisory-only`,
|
|
692
|
+
message: `CI is not fail-closed. Workflow ${named} runs ark-check but a skippable if: is not a merge line.`,
|
|
693
|
+
};
|
|
608
694
|
}
|
|
609
695
|
|
|
610
696
|
export function missingGates(root) {
|
|
@@ -615,7 +701,7 @@ export function missingGates(root) {
|
|
|
615
701
|
if (compactHost && !hasCompactHostRegistration(root, compactHost)) {
|
|
616
702
|
missing.push(`compact host registration (${compactHost})`);
|
|
617
703
|
}
|
|
618
|
-
if (!
|
|
704
|
+
if (!inspectArkCiGate(root).present) missing.push(REQUIRED_GATE_WORKFLOW);
|
|
619
705
|
return missing;
|
|
620
706
|
}
|
|
621
707
|
|
|
@@ -494,8 +494,12 @@ export function planManagedUpgrade(root, options = {}) {
|
|
|
494
494
|
action: canApply ? (currentScoped == null ? 'create' : 'update') : 'none',
|
|
495
495
|
willApply: canApply,
|
|
496
496
|
blocked,
|
|
497
|
+
// Raw scoped bytes. state/willApply use identity, not these hashes.
|
|
497
498
|
beforeHash: hash(currentScoped == null ? null : Buffer.from(currentScoped)),
|
|
498
499
|
afterHash: hash(Buffer.from(desiredScoped)),
|
|
500
|
+
beforeIdentity:
|
|
501
|
+
currentScoped == null ? null : managedContentIdentity(currentScoped, catalogAsset.kind),
|
|
502
|
+
afterIdentity: managedContentIdentity(desiredScoped, catalogAsset.kind),
|
|
499
503
|
containerBeforeHash: hash(currentFile),
|
|
500
504
|
[AFTER_CONTENT]: desiredFile,
|
|
501
505
|
};
|
|
@@ -531,6 +535,8 @@ export function planManagedUpgrade(root, options = {}) {
|
|
|
531
535
|
blocked: false,
|
|
532
536
|
beforeHash: recorded.baseHash,
|
|
533
537
|
afterHash: null,
|
|
538
|
+
beforeIdentity: recorded.contentIdentity ?? null,
|
|
539
|
+
afterIdentity: null,
|
|
534
540
|
containerBeforeHash: null,
|
|
535
541
|
});
|
|
536
542
|
}
|
|
@@ -834,7 +840,9 @@ function assertAssetUnchanged(root, asset) {
|
|
|
834
840
|
export function applyManagedUpgrade(root, plan, expectedPlanDigest) {
|
|
835
841
|
const resolvedRoot = path.resolve(root);
|
|
836
842
|
if (resolvedRoot !== plan.root) throw new Error('managed upgrade plan root mismatch');
|
|
837
|
-
if (plan.summary.blocked > 0)
|
|
843
|
+
if (plan.summary.blocked > 0) {
|
|
844
|
+
return publicPlan(plan, { blocked: true, reasonCode: 'managed-consent-required' });
|
|
845
|
+
}
|
|
838
846
|
const wouldWrite = plan.summary.wouldWrite ?? 0;
|
|
839
847
|
// Content already matches: unbound --apply is a no-op (exit success), not a digest error.
|
|
840
848
|
if (!expectedPlanDigest || expectedPlanDigest !== plan.planDigest) {
|
|
@@ -750,6 +750,83 @@ function declaredIntent(value, config) {
|
|
|
750
750
|
);
|
|
751
751
|
}
|
|
752
752
|
|
|
753
|
+
/** Call names whose string arguments are declared intent-reference sites. */
|
|
754
|
+
const INTENT_CALL_NAMES = new Set(['publish', 'subscribe', 'defineIntent', 'registerHandler']);
|
|
755
|
+
|
|
756
|
+
function isSyntaxWrapper(ts, node) {
|
|
757
|
+
return Boolean(
|
|
758
|
+
node &&
|
|
759
|
+
(ts.isParenthesizedExpression(node) ||
|
|
760
|
+
ts.isAsExpression(node) ||
|
|
761
|
+
(typeof ts.isTypeAssertionExpression === 'function' &&
|
|
762
|
+
ts.isTypeAssertionExpression(node)) ||
|
|
763
|
+
(typeof ts.isSatisfiesExpression === 'function' && ts.isSatisfiesExpression(node)))
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function unwrapWrappers(ts, node) {
|
|
768
|
+
let current = node;
|
|
769
|
+
while (current?.parent && isSyntaxWrapper(ts, current.parent)) {
|
|
770
|
+
current = current.parent;
|
|
771
|
+
}
|
|
772
|
+
return current;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function callCalleeName(ts, node) {
|
|
776
|
+
if (!node || !ts.isCallExpression(node)) return undefined;
|
|
777
|
+
const expression = node.expression;
|
|
778
|
+
if (ts.isIdentifier(expression)) return expression.text;
|
|
779
|
+
if (ts.isPropertyAccessExpression(expression)) return expression.name.text;
|
|
780
|
+
return undefined;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function isPublishMetadataSource(ts, sourceProp) {
|
|
784
|
+
const object = sourceProp.parent;
|
|
785
|
+
if (!object || !ts.isObjectLiteralExpression(object)) return false;
|
|
786
|
+
const objectSite = unwrapWrappers(ts, object);
|
|
787
|
+
const objectParent = objectSite.parent;
|
|
788
|
+
if (!objectParent) return false;
|
|
789
|
+
if (ts.isCallExpression(objectParent) && callCalleeName(ts, objectParent) === 'publish') {
|
|
790
|
+
const args = objectParent.arguments;
|
|
791
|
+
return args[1] === objectSite || args[2] === objectSite;
|
|
792
|
+
}
|
|
793
|
+
if (
|
|
794
|
+
ts.isPropertyAssignment(objectParent) &&
|
|
795
|
+
syntaxPropertyName(ts, objectParent.name) === 'metadata'
|
|
796
|
+
) {
|
|
797
|
+
const eventObject = objectParent.parent;
|
|
798
|
+
if (!eventObject) return false;
|
|
799
|
+
const eventSite = unwrapWrappers(ts, eventObject);
|
|
800
|
+
const call = eventSite.parent;
|
|
801
|
+
return Boolean(
|
|
802
|
+
call && ts.isCallExpression(call) && callCalleeName(ts, call) === 'publish'
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
return false;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/** Events, sagas, and publish metadata — not every string that matches a prefix. */
|
|
809
|
+
function isDeclaredIntentSite(ts, node) {
|
|
810
|
+
const siteNode = unwrapWrappers(ts, node);
|
|
811
|
+
const parent = siteNode.parent;
|
|
812
|
+
if (!parent) return false;
|
|
813
|
+
if (ts.isCallExpression(parent)) {
|
|
814
|
+
const name = callCalleeName(ts, parent);
|
|
815
|
+
if (INTENT_CALL_NAMES.has(name) && parent.arguments.some((arg) => arg === siteNode)) {
|
|
816
|
+
return true;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
if (ts.isArrayLiteralExpression(parent)) {
|
|
820
|
+
return isDeclaredIntentSite(ts, parent);
|
|
821
|
+
}
|
|
822
|
+
if (ts.isPropertyAssignment(parent)) {
|
|
823
|
+
const name = syntaxPropertyName(ts, parent.name);
|
|
824
|
+
if (name === 'intent' || name === 'onEvent' || name === 'reactsTo') return true;
|
|
825
|
+
if (name === 'source' && isPublishMetadataSource(ts, parent)) return true;
|
|
826
|
+
}
|
|
827
|
+
return false;
|
|
828
|
+
}
|
|
829
|
+
|
|
753
830
|
function mayContainForbiddenCapability(ts, sourceFile, forbiddenGlobals) {
|
|
754
831
|
// Every symbol-aware match originates in an identifier or static string path segment.
|
|
755
832
|
// Inspect decoded AST text so escaped identifiers still take the full checker path.
|
|
@@ -788,7 +865,11 @@ function collectPolicyFacts(ts, sourceFile, relativePath, config) {
|
|
|
788
865
|
: {}),
|
|
789
866
|
});
|
|
790
867
|
}
|
|
791
|
-
if (
|
|
868
|
+
if (
|
|
869
|
+
ts.isStringLiteralLike(node) &&
|
|
870
|
+
declaredIntent(node.text, config) &&
|
|
871
|
+
isDeclaredIntentSite(ts, node)
|
|
872
|
+
) {
|
|
792
873
|
intentReferences.push({
|
|
793
874
|
file: relativePath,
|
|
794
875
|
line: lineOf(sourceFile, node.getStart(sourceFile)),
|