@intentius/chant 0.79.0 → 0.80.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/dist/audit/core.d.ts.map +1 -1
- package/dist/audit/discover.d.ts +38 -0
- package/dist/audit/discover.d.ts.map +1 -1
- package/dist/audit/terraform-state.d.ts +14 -0
- package/dist/audit/terraform-state.d.ts.map +1 -1
- package/dist/cli/command-group.d.ts +6 -0
- package/dist/cli/command-group.d.ts.map +1 -1
- package/dist/cli/commands/audit.d.ts +27 -2
- package/dist/cli/commands/audit.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon-examples.d.ts.map +1 -1
- package/dist/cli/commands/doctor.d.ts.map +1 -1
- package/dist/cli/commands/import-agents.d.ts.map +1 -1
- package/dist/cli/commands/init.d.ts +6 -0
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/commands/onboard.d.ts.map +1 -1
- package/dist/cli/commands/update.d.ts.map +1 -1
- package/dist/cli/conflict-check.d.ts +6 -0
- package/dist/cli/conflict-check.d.ts.map +1 -1
- package/dist/cli/handlers/dev.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/misc.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/promote.d.ts +17 -0
- package/dist/cli/handlers/promote.d.ts.map +1 -0
- package/dist/cli/handlers/run-generate.d.ts +39 -0
- package/dist/cli/handlers/run-generate.d.ts.map +1 -0
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/op-tools.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts +9 -0
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +14 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/capability-plugin-loader.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/discover.d.ts.map +1 -1
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/fan-out-support.d.ts.map +1 -1
- package/dist/components/pilots/alb-ecs.pilot.d.ts +7 -1
- package/dist/components/pilots/alb-ecs.pilot.d.ts.map +1 -1
- package/dist/components/presets/ecs-fargate.d.ts +10 -4
- package/dist/components/presets/ecs-fargate.d.ts.map +1 -1
- package/dist/components/promote.d.ts +211 -0
- package/dist/components/promote.d.ts.map +1 -0
- package/dist/components/unbound-gate-approval.d.ts +24 -0
- package/dist/components/unbound-gate-approval.d.ts.map +1 -0
- package/dist/config.d.ts +82 -5
- package/dist/config.d.ts.map +1 -1
- package/dist/discovery/convergence.d.ts +97 -0
- package/dist/discovery/convergence.d.ts.map +1 -0
- package/dist/discovery/files.d.ts +41 -2
- package/dist/discovery/files.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts +15 -0
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +13 -0
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/lexicon-module.d.ts +65 -0
- package/dist/lexicon-module.d.ts.map +1 -0
- package/dist/lexicon.d.ts +24 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/build-ledger-store.d.ts.map +1 -1
- package/dist/lifecycle/legacy-digest.d.ts +26 -0
- package/dist/lifecycle/legacy-digest.d.ts.map +1 -0
- package/dist/lifecycle/release-ledger.d.ts +25 -0
- package/dist/lifecycle/release-ledger.d.ts.map +1 -1
- package/dist/lint/rules/comp/comp003-mutating-no-rollback.d.ts +2 -3
- package/dist/lint/rules/comp/comp003-mutating-no-rollback.d.ts.map +1 -1
- package/dist/op/activity-contract-registry.d.ts.map +1 -1
- package/dist/op/activity-registry.d.ts.map +1 -1
- package/dist/op/discover.d.ts.map +1 -1
- package/dist/op/gate-approval.d.ts.map +1 -1
- package/dist/op/generate-pipeline.d.ts.map +1 -1
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/workspace/record-source.d.ts +22 -0
- package/dist/workspace/record-source.d.ts.map +1 -0
- package/dist/workspace/records-cli.d.ts +57 -0
- package/dist/workspace/records-cli.d.ts.map +1 -0
- package/dist/workspace/records.d.ts +118 -0
- package/dist/workspace/records.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/audit/core.ts +13 -2
- package/src/audit/discover.ts +90 -12
- package/src/audit/terraform-state.test.ts +86 -1
- package/src/audit/terraform-state.ts +26 -0
- package/src/cli/command-group.ts +7 -0
- package/src/cli/commands/audit-walk-warnings.test.ts +156 -0
- package/src/cli/commands/audit.ts +93 -19
- package/src/cli/commands/check-lexicon-examples.ts +2 -1
- package/src/cli/commands/doctor.ts +3 -0
- package/src/cli/commands/import-agents.ts +11 -4
- package/src/cli/commands/init.ts +57 -25
- package/src/cli/commands/lint.test.ts +58 -1
- package/src/cli/commands/lint.ts +48 -18
- package/src/cli/commands/onboard.ts +15 -0
- package/src/cli/commands/update.ts +5 -1
- package/src/cli/conflict-check.test.ts +18 -1
- package/src/cli/conflict-check.ts +13 -0
- package/src/cli/discovery-skip.test.ts +168 -0
- package/src/cli/handlers/build.test.ts +11 -0
- package/src/cli/handlers/build.ts +1 -1
- package/src/cli/handlers/dev.ts +3 -0
- package/src/cli/handlers/graph.test.ts +20 -0
- package/src/cli/handlers/graph.ts +4 -2
- package/src/cli/handlers/lifecycle.ts +2 -1
- package/src/cli/handlers/misc.ts +16 -0
- package/src/cli/handlers/operator.ts +2 -1
- package/src/cli/handlers/promote.test.ts +261 -0
- package/src/cli/handlers/promote.ts +325 -0
- package/src/cli/handlers/run-generate.test.ts +160 -0
- package/src/cli/handlers/run-generate.ts +133 -0
- package/src/cli/handlers/run.ts +13 -1
- package/src/cli/main.test.ts +33 -1
- package/src/cli/main.ts +69 -16
- package/src/cli/mcp/op-tools.ts +2 -1
- package/src/cli/path-lexicon-messages.test.ts +152 -0
- package/src/cli/plugins.ts +44 -6
- package/src/cli/registry.ts +14 -1
- package/src/components/__fixtures__/alb-ecs-service.json +0 -8
- package/src/components/capability-plugin-loader.ts +3 -1
- package/src/components/cli-support.ts +3 -2
- package/src/components/discover.ts +26 -2
- package/src/components/driver.test.ts +12 -11
- package/src/components/driver.ts +2 -0
- package/src/components/fan-out-support.ts +2 -1
- package/src/components/pilots/README.md +1 -1
- package/src/components/pilots/alb-ecs.pilot.ts +7 -7
- package/src/components/presets/ecs-fargate.ts +10 -5
- package/src/components/promote.test.ts +371 -0
- package/src/components/promote.ts +537 -0
- package/src/components/unbound-gate-approval.test.ts +82 -0
- package/src/components/unbound-gate-approval.ts +51 -0
- package/src/config.test.ts +40 -0
- package/src/config.ts +128 -11
- package/src/discovery/convergence.test.ts +231 -0
- package/src/discovery/convergence.ts +399 -0
- package/src/discovery/files.test.ts +127 -1
- package/src/discovery/files.ts +97 -9
- package/src/discovery/fold-import.ts +83 -9
- package/src/discovery/sandbox/path-lexicon-parity.test.ts +162 -0
- package/src/graph-ir.ts +14 -0
- package/src/lexicon-module.test.ts +203 -0
- package/src/lexicon-module.ts +117 -0
- package/src/lexicon.ts +26 -0
- package/src/lifecycle/build-ledger-store.ts +11 -1
- package/src/lifecycle/legacy-digest.test.ts +114 -0
- package/src/lifecycle/legacy-digest.ts +62 -0
- package/src/lifecycle/release-ledger.ts +34 -0
- package/src/lint/rules/comp/comp003-mutating-no-rollback.ts +2 -3
- package/src/op/activity-contract-registry.ts +18 -4
- package/src/op/activity-registry.ts +26 -0
- package/src/op/discover-convergence.test.ts +70 -0
- package/src/op/discover.ts +4 -0
- package/src/op/gate-approval.ts +3 -1
- package/src/op/generate-pipeline.ts +2 -1
- package/src/op/runtimes/local.ts +2 -1
- package/src/workspace/record-source.ts +117 -0
- package/src/workspace/records-cli.ts +126 -0
- package/src/workspace/records-contract.test.ts +88 -0
- package/src/workspace/records.schema.json +120 -0
- package/src/workspace/records.test.ts +243 -0
- package/src/workspace/records.ts +383 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"records-cli.d.ts","sourceRoot":"","sources":["../../src/workspace/records-cli.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAIH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEtD,OAAO,EAAgD,KAAK,aAAa,EAAE,KAAK,WAAW,EAAE,MAAM,WAAW,CAAC;AAE/G,6DAA6D;AAC7D,eAAO,MAAM,wBAAwB,IAAI,CAAC;AAE1C,kFAAkF;AAClF,eAAO,MAAM,wBAAwB,gFAAgF,CAAC;AAItH,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,iEAAiE;IACjE,GAAG,EAAE,MAAM,CAAC;CACb;AAED,wEAAwE;AACxE,MAAM,MAAM,eAAe,GACvB;IACE,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC;IACrD,EAAE,EAAE,MAAM,GAAG,IAAI,CAAC;IAClB,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,EAAE,WAAW,EAAE,CAAC;IACvB,OAAO,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE,CAAC;CAChF,GACD;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE;QAAE,IAAI,EAAE,aAAa,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,CAAC;AAE3F,oGAAoG;AACpG,wBAAsB,YAAY,CAAC,KAAK,EAAE,YAAY,GAAG,OAAO,CAAC,eAAe,CAAC,CA8BhF;AAED,wBAAsB,mBAAmB,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAe9E;AAmBD,uEAAuE;AACvE,wBAAsB,mBAAmB,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAS9E"}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Records read through a record kind (#2524 D4), first-test slice (#2546).
|
|
3
|
+
*
|
|
4
|
+
* A record kind is data. It says where its records live, which JSON Schema
|
|
5
|
+
* they follow and which of their states are closed. This module reads every
|
|
6
|
+
* record a kind locates, parses its front matter as the JSON subset of YAML,
|
|
7
|
+
* validates it against the kind's schema and derives supersession from the
|
|
8
|
+
* records' own `supersedes` links. It never writes a record.
|
|
9
|
+
*
|
|
10
|
+
* A record that fails any of that is still returned, with reason codes, and the
|
|
11
|
+
* read succeeds. Only a failure to read the kind, its schema or the revision is
|
|
12
|
+
* an error. Seals, attestation and the workspace declaration come later (#2546,
|
|
13
|
+
* #2534); nothing here needs a `chant.workspace.json`.
|
|
14
|
+
*
|
|
15
|
+
* Everything under `workspace/` loads only when a `chant workspace` command
|
|
16
|
+
* runs. The level-0 goldens (#2526) fail if a level-0 command loads it.
|
|
17
|
+
*/
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
import type { RecordSource } from "./record-source.js";
|
|
20
|
+
/**
|
|
21
|
+
* Why one record is not valid. The list is closed: a reader may switch on it,
|
|
22
|
+
* and a new code is a contract change (#2536).
|
|
23
|
+
*/
|
|
24
|
+
export declare const RECORD_REASON_CODES: readonly ["record-unparseable", "record-schema-invalid", "record-id-duplicate", "record-supersedes-unknown", "record-supersedes-conflict"];
|
|
25
|
+
export type RecordReasonCode = (typeof RECORD_REASON_CODES)[number];
|
|
26
|
+
/**
|
|
27
|
+
* Why the read as a whole failed. Also closed. The command exits 1 with one of
|
|
28
|
+
* these and returns no records.
|
|
29
|
+
*/
|
|
30
|
+
export declare const READ_ERROR_CODES: readonly ["kind-unreadable", "kind-invalid", "schema-unreadable", "schema-id-mismatch", "schema-invalid", "location-missing", "not-a-git-repository", "revision-unknown"];
|
|
31
|
+
export type ReadErrorCode = (typeof READ_ERROR_CODES)[number];
|
|
32
|
+
export declare class RecordReadError extends Error {
|
|
33
|
+
readonly code: ReadErrorCode;
|
|
34
|
+
constructor(code: ReadErrorCode, message: string);
|
|
35
|
+
}
|
|
36
|
+
/** The data a kind file exports as `recordKind`. */
|
|
37
|
+
export declare const recordKindSchema: z.ZodObject<{
|
|
38
|
+
name: z.ZodString;
|
|
39
|
+
location: z.ZodObject<{
|
|
40
|
+
dir: z.ZodString;
|
|
41
|
+
match: z.ZodString;
|
|
42
|
+
}, z.core.$strict>;
|
|
43
|
+
format: z.ZodLiteral<"markdown-front-matter">;
|
|
44
|
+
schema: z.ZodObject<{
|
|
45
|
+
id: z.ZodString;
|
|
46
|
+
path: z.ZodString;
|
|
47
|
+
}, z.core.$strict>;
|
|
48
|
+
idField: z.ZodString;
|
|
49
|
+
stateField: z.ZodString;
|
|
50
|
+
states: z.ZodArray<z.ZodString>;
|
|
51
|
+
closedStates: z.ZodArray<z.ZodString>;
|
|
52
|
+
supersedes: z.ZodObject<{
|
|
53
|
+
field: z.ZodString;
|
|
54
|
+
key: z.ZodString;
|
|
55
|
+
}, z.core.$strict>;
|
|
56
|
+
}, z.core.$strict>;
|
|
57
|
+
export type RecordKind = z.infer<typeof recordKindSchema>;
|
|
58
|
+
/** A kind as loaded: its data, where it came from, and its schema. */
|
|
59
|
+
export interface LoadedRecordKind {
|
|
60
|
+
kind: RecordKind;
|
|
61
|
+
/** Absolute path of the kind file. */
|
|
62
|
+
file: string;
|
|
63
|
+
/** Absolute path of the records directory in the working tree. */
|
|
64
|
+
dir: string;
|
|
65
|
+
schema: Record<string, unknown>;
|
|
66
|
+
}
|
|
67
|
+
/** Load a kind file and the schema it names. `path` is resolved against `cwd`. */
|
|
68
|
+
export declare function loadRecordKind(path: string, cwd?: string): Promise<LoadedRecordKind>;
|
|
69
|
+
export type FrontMatter = {
|
|
70
|
+
ok: true;
|
|
71
|
+
value: Record<string, unknown>;
|
|
72
|
+
} | {
|
|
73
|
+
ok: false;
|
|
74
|
+
message: string;
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* The front matter of a Markdown file, limited to what YAML and JSON share
|
|
78
|
+
* (#2524 D4). Line endings are normalised first. Aliases, and any value JSON
|
|
79
|
+
* cannot hold, are refused, so the same text always reads as the same JSON.
|
|
80
|
+
*/
|
|
81
|
+
export declare function parseFrontMatter(text: string): FrontMatter;
|
|
82
|
+
export interface RecordReason {
|
|
83
|
+
code: RecordReasonCode;
|
|
84
|
+
message: string;
|
|
85
|
+
}
|
|
86
|
+
export interface RecordEntry {
|
|
87
|
+
/** The record's id, or null when it could not be read. */
|
|
88
|
+
id: string | null;
|
|
89
|
+
/** Path from the repository root (or the working directory outside git), with `/` separators. */
|
|
90
|
+
path: string;
|
|
91
|
+
/** The record's state as written, or null when it could not be read. */
|
|
92
|
+
state: string | null;
|
|
93
|
+
valid: boolean;
|
|
94
|
+
reasons: RecordReason[];
|
|
95
|
+
/** The id of the closed record whose `supersedes` link replaces this one, or null. */
|
|
96
|
+
supersededBy: string | null;
|
|
97
|
+
/** The front matter as JSON, or null when it could not be parsed. */
|
|
98
|
+
data: Record<string, unknown> | null;
|
|
99
|
+
}
|
|
100
|
+
export interface ReadRecordsOptions {
|
|
101
|
+
/** Where record paths are reported from, and what `source` reads relative to. */
|
|
102
|
+
root: string;
|
|
103
|
+
source: RecordSource;
|
|
104
|
+
/** Leave out records a closed record supersedes. */
|
|
105
|
+
current?: boolean;
|
|
106
|
+
}
|
|
107
|
+
export interface ReadRecordsResult {
|
|
108
|
+
records: RecordEntry[];
|
|
109
|
+
summary: {
|
|
110
|
+
total: number;
|
|
111
|
+
valid: number;
|
|
112
|
+
invalid: number;
|
|
113
|
+
superseded: number;
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/** Read every record `loaded` locates, through `options.source`. */
|
|
117
|
+
export declare function readRecords(loaded: LoadedRecordKind, options: ReadRecordsOptions): Promise<ReadRecordsResult>;
|
|
118
|
+
//# sourceMappingURL=records.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"records.d.ts","sourceRoot":"","sources":["../../src/workspace/records.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAKH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAIpD;;;GAGG;AACH,eAAO,MAAM,mBAAmB,4IAWtB,CAAC;AACX,MAAM,MAAM,gBAAgB,GAAG,CAAC,OAAO,mBAAmB,CAAC,CAAC,MAAM,CAAC,CAAC;AAEpE;;;GAGG;AACH,eAAO,MAAM,gBAAgB,2KAiBnB,CAAC;AACX,MAAM,MAAM,aAAa,GAAG,CAAC,OAAO,gBAAgB,CAAC,CAAC,MAAM,CAAC,CAAC;AAE9D,qBAAa,eAAgB,SAAQ,KAAK;IAEtC,QAAQ,CAAC,IAAI,EAAE,aAAa;gBAAnB,IAAI,EAAE,aAAa,EAC5B,OAAO,EAAE,MAAM;CAKlB;AAMD,oDAAoD;AACpD,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;kBAoCzB,CAAC;AAEL,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAE1D,sEAAsE;AACtE,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,UAAU,CAAC;IACjB,sCAAsC;IACtC,IAAI,EAAE,MAAM,CAAC;IACb,kEAAkE;IAClE,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACjC;AAmBD,kFAAkF;AAClF,wBAAsB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,GAAE,MAAsB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAiCzG;AAID,MAAM,MAAM,WAAW,GAAG;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAExG;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,CAgB1D;AA0BD,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,gBAAgB,CAAC;IACvB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,WAAW;IAC1B,0DAA0D;IAC1D,EAAE,EAAE,MAAM,GAAG,IAAI,CAAC;IAClB,iGAAiG;IACjG,IAAI,EAAE,MAAM,CAAC;IACb,wEAAwE;IACxE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,KAAK,EAAE,OAAO,CAAC;IACf,OAAO,EAAE,YAAY,EAAE,CAAC;IACxB,sFAAsF;IACtF,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,qEAAqE;IACrE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;CACtC;AAED,MAAM,WAAW,kBAAkB;IACjC,iFAAiF;IACjF,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,YAAY,CAAC;IACrB,oDAAoD;IACpD,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,WAAW,EAAE,CAAC;IACvB,OAAO,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE,CAAC;CAChF;AAsBD,oEAAoE;AACpE,wBAAsB,WAAW,CAAC,MAAM,EAAE,gBAAgB,EAAE,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAgFnH"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.80.0",
|
|
4
4
|
"description": "Declarative infrastructure-as-code toolkit — TypeScript on Node.js",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://intentius.io/chant",
|
|
@@ -91,6 +91,7 @@
|
|
|
91
91
|
},
|
|
92
92
|
"dependencies": {
|
|
93
93
|
"@dagrejs/dagre": "^3.0.0",
|
|
94
|
+
"ajv": "^8.20.0",
|
|
94
95
|
"esbuild": "^0.28.1",
|
|
95
96
|
"fflate": "^0.8.2",
|
|
96
97
|
"js-yaml": "^4.3.1",
|
|
@@ -100,8 +101,7 @@
|
|
|
100
101
|
"zod": "^4.3.6"
|
|
101
102
|
},
|
|
102
103
|
"devDependencies": {
|
|
103
|
-
"@cdktn/hcl2json": "^0.24.0"
|
|
104
|
-
"ajv": "^8.20.0"
|
|
104
|
+
"@cdktn/hcl2json": "^0.24.0"
|
|
105
105
|
},
|
|
106
106
|
"overrides": {
|
|
107
107
|
"esbuild": "^0.28.1"
|
package/src/audit/core.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { applyInlineSuppressions, type SuppressionMetaFinding } from "../lint/su
|
|
|
27
27
|
import type { SerializerResult } from "../serializer";
|
|
28
28
|
import type { AuditEntitiesInput, LexiconPlugin } from "../lexicon";
|
|
29
29
|
import type { Declarable } from "../declarable";
|
|
30
|
+
import { lexiconModulePath, lexiconPackagesToInstall, lexiconSourceLabel } from "../lexicon-module";
|
|
30
31
|
|
|
31
32
|
/**
|
|
32
33
|
* The lexicon whose post-synth checks run against an audited file. Any lexicon
|
|
@@ -155,9 +156,19 @@ async function load(names: string[]): Promise<LexiconPlugin[]> {
|
|
|
155
156
|
const { loadPlugins } = await import("../cli/plugins");
|
|
156
157
|
return await loadPlugins(names);
|
|
157
158
|
} catch (err) {
|
|
158
|
-
const
|
|
159
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
160
|
+
// chant#2578 — a lexicon declared by path in chant.config.ts has no
|
|
161
|
+
// package: name its module and print no install line for it.
|
|
162
|
+
const pkgs = lexiconPackagesToInstall(names);
|
|
163
|
+
const paths = names.filter((n) => lexiconModulePath(n) !== undefined).map((n) => lexiconSourceLabel(n));
|
|
164
|
+
if (pkgs.length === 0) {
|
|
165
|
+
throw new MissingLexiconError(
|
|
166
|
+
`Could not load the lexicon module ${paths.join(", ")} needed to audit ${names.join("/")} workflows.\n(${detail})`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
const fromPath = paths.length > 0 ? ` The lexicon module ${paths.join(", ")} is declared by path and has nothing to install.` : "";
|
|
159
170
|
throw new MissingLexiconError(
|
|
160
|
-
`Missing lexicon package needed to audit ${names.join("/")} workflows. Install it with: npm i ${pkgs}\n(${
|
|
171
|
+
`Missing lexicon package needed to audit ${names.join("/")} workflows. Install it with: npm i ${pkgs.join(" ")}${fromPath}\n(${detail})`,
|
|
161
172
|
);
|
|
162
173
|
}
|
|
163
174
|
}
|
package/src/audit/discover.ts
CHANGED
|
@@ -40,7 +40,7 @@ import { parseYAML } from "../yaml";
|
|
|
40
40
|
import type { LexiconPlugin } from "../lexicon";
|
|
41
41
|
import type { AuditInput, AuditLexicon } from "./core";
|
|
42
42
|
import { isNginxConfigPath } from "./nginx";
|
|
43
|
-
import { gitignoreCoversTerraformState, isTerraformStatePath } from "./terraform-state";
|
|
43
|
+
import { gitignoreCoversTerraformState, isTerraformStatePath, nestedGitignoreCovering } from "./terraform-state";
|
|
44
44
|
|
|
45
45
|
/** Lexicons the auditor knows how to detect and run checks for. */
|
|
46
46
|
export const AUDIT_LEXICONS = ["github", "gitlab", "forgejo", "k8s", "docker", "aws", "azure", "gcp", "helm", "fountain", "terraform"] as const;
|
|
@@ -74,34 +74,61 @@ const WALK_SKIP = new Set(["node_modules", ".git", "dist"]);
|
|
|
74
74
|
const TERRAFORM_WORK_DIR = ".terraform";
|
|
75
75
|
/** Dot-directories the walk descends into anyway (CI lives here). */
|
|
76
76
|
const WALK_DOT_DIRS = new Set([".github", ".forgejo"]);
|
|
77
|
-
|
|
77
|
+
/**
|
|
78
|
+
* How many files the local walk takes before it stops. Every file it reaches
|
|
79
|
+
* counts, candidate or not, so a tree with a large `docs/` or `assets/` can hit
|
|
80
|
+
* it long before a thousand auditable files. `--max-files` raises it (#2528).
|
|
81
|
+
*/
|
|
82
|
+
export const DEFAULT_MAX_WALK_FILES = 1000;
|
|
78
83
|
/** Skip absurdly large files before parsing — mirrors the fetch layer's caps. */
|
|
79
84
|
const MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
80
85
|
|
|
81
|
-
/**
|
|
82
|
-
|
|
83
|
-
|
|
86
|
+
/** The walk's limit, and whether it left anything behind because of it. */
|
|
87
|
+
interface WalkState {
|
|
88
|
+
limit: number;
|
|
89
|
+
truncated: boolean;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Recursively collect file paths under a root, skipping noise dirs.
|
|
94
|
+
*
|
|
95
|
+
* Stops at `state.limit` paths. Once full it keeps looking only until it meets
|
|
96
|
+
* one more path it would have taken, which is what sets `truncated`: a tree of
|
|
97
|
+
* exactly `limit` files, or one whose remaining entries are all skipped
|
|
98
|
+
* directories, was not truncated. The paths it returns are the same first
|
|
99
|
+
* `limit` either way.
|
|
100
|
+
*/
|
|
101
|
+
function walkFiles(dir: string, out: string[], state: WalkState): void {
|
|
102
|
+
if (state.truncated) return;
|
|
84
103
|
let entries;
|
|
85
104
|
try {
|
|
86
105
|
entries = readdirSync(dir, { withFileTypes: true });
|
|
87
106
|
} catch {
|
|
88
107
|
return;
|
|
89
108
|
}
|
|
109
|
+
const take = (path: string): boolean => {
|
|
110
|
+
if (out.length >= state.limit) {
|
|
111
|
+
state.truncated = true;
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
out.push(path);
|
|
115
|
+
return true;
|
|
116
|
+
};
|
|
90
117
|
for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
91
|
-
if (
|
|
118
|
+
if (state.truncated) return;
|
|
92
119
|
if (e.name.startsWith(".") && e.isDirectory() && !WALK_DOT_DIRS.has(e.name)) {
|
|
93
120
|
// `.terraform/` is never descended into: it is machine-generated,
|
|
94
121
|
// routinely hundreds of megabytes of provider binaries, and nothing in
|
|
95
122
|
// it is worth reading. Its presence is itself the finding (TF023), so
|
|
96
123
|
// the directory path alone is recorded and `collectCandidates` hands it
|
|
97
124
|
// on with no content.
|
|
98
|
-
if (e.name === TERRAFORM_WORK_DIR
|
|
125
|
+
if (e.name === TERRAFORM_WORK_DIR && !take(join(dir, e.name))) return;
|
|
99
126
|
continue;
|
|
100
127
|
}
|
|
101
128
|
if (WALK_SKIP.has(e.name)) continue;
|
|
102
129
|
const full = join(dir, e.name);
|
|
103
|
-
if (e.isDirectory()) walkFiles(full, out);
|
|
104
|
-
else
|
|
130
|
+
if (e.isDirectory()) walkFiles(full, out, state);
|
|
131
|
+
else if (!take(full)) return;
|
|
105
132
|
}
|
|
106
133
|
}
|
|
107
134
|
|
|
@@ -594,10 +621,49 @@ export function discoverByDetection(root: string, plugins: DetectPlugin[]): Audi
|
|
|
594
621
|
return classifyFiles(collectCandidates(root), plugins, { baseDir: root });
|
|
595
622
|
}
|
|
596
623
|
|
|
624
|
+
/** Options for the local walk. */
|
|
625
|
+
export interface CandidateWalkOptions {
|
|
626
|
+
/** Files the walk takes before it stops, counting every file. Defaults to {@link DEFAULT_MAX_WALK_FILES}. */
|
|
627
|
+
maxFiles?: number;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* A TF023 path the root `.gitignore` does not cover but a `.gitignore` in a
|
|
632
|
+
* directory between it and the scan root does (#2528). TF023 reports it today;
|
|
633
|
+
* from the next release, which reads every such `.gitignore`, it will not.
|
|
634
|
+
*/
|
|
635
|
+
export interface NestedGitignoreChange {
|
|
636
|
+
/** The TF023 path, relative to the scan root. */
|
|
637
|
+
path: string;
|
|
638
|
+
/** The nested `.gitignore` that ignores it, relative to the scan root. */
|
|
639
|
+
gitignore: string;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** What the local walk read, plus what it knows about its own limits. */
|
|
643
|
+
export interface CandidateWalk {
|
|
644
|
+
files: RepoFile[];
|
|
645
|
+
/** The limit the walk ran with. */
|
|
646
|
+
maxFiles: number;
|
|
647
|
+
/** True when the walk stopped at `maxFiles` with files left unread. */
|
|
648
|
+
truncated: boolean;
|
|
649
|
+
/** TF023 paths whose finding changes once nested `.gitignore` files are read. */
|
|
650
|
+
nestedGitignore: NestedGitignoreChange[];
|
|
651
|
+
}
|
|
652
|
+
|
|
597
653
|
/** The walk half of `discoverByDetection`: candidate files read into memory, paths relative to the root. */
|
|
598
654
|
export function collectCandidates(root: string): RepoFile[] {
|
|
655
|
+
return walkCandidates(root).files;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* `collectCandidates` with the facts the audit CLI warns about: whether the
|
|
660
|
+
* walk was truncated, and which TF023 paths a nested `.gitignore` would drop.
|
|
661
|
+
* Neither changes `files`; the CLI prints them on stderr (#2528).
|
|
662
|
+
*/
|
|
663
|
+
export function walkCandidates(root: string, opts: CandidateWalkOptions = {}): CandidateWalk {
|
|
599
664
|
const all: string[] = [];
|
|
600
|
-
|
|
665
|
+
const state: WalkState = { limit: opts.maxFiles ?? DEFAULT_MAX_WALK_FILES, truncated: false };
|
|
666
|
+
walkFiles(root, all, state);
|
|
601
667
|
// TF023 reads paths, not content: a `.tfstate` can be tens of megabytes of
|
|
602
668
|
// machine-generated JSON, and the finding is that it is in the repository at
|
|
603
669
|
// all. The root `.gitignore` is what separates "committed" from "merely on
|
|
@@ -606,11 +672,23 @@ export function collectCandidates(root: string): RepoFile[] {
|
|
|
606
672
|
// local audit. A fetched repository has no such ambiguity (its file list is
|
|
607
673
|
// the tracked files) and is not filtered.
|
|
608
674
|
const gitignore = readSafe(join(root, ".gitignore")) ?? "";
|
|
675
|
+
// Only the root one decides, for now. The nested ones are read to say which
|
|
676
|
+
// findings the next release, which honours them, will stop reporting.
|
|
677
|
+
const nestedBodies = new Map<string, string | undefined>();
|
|
678
|
+
const nestedGitignoreAt = (dir: string): string | undefined => {
|
|
679
|
+
if (!nestedBodies.has(dir)) nestedBodies.set(dir, readSafe(join(root, dir, ".gitignore")));
|
|
680
|
+
return nestedBodies.get(dir);
|
|
681
|
+
};
|
|
609
682
|
const files: RepoFile[] = [];
|
|
683
|
+
const nestedGitignore: NestedGitignoreChange[] = [];
|
|
610
684
|
for (const full of all) {
|
|
611
685
|
const path = relative(root, full);
|
|
612
686
|
if (isTerraformStatePath(path)) {
|
|
613
|
-
if (!gitignoreCoversTerraformState(gitignore, path))
|
|
687
|
+
if (!gitignoreCoversTerraformState(gitignore, path)) {
|
|
688
|
+
files.push({ path, content: "" });
|
|
689
|
+
const dir = nestedGitignoreCovering(path, nestedGitignoreAt);
|
|
690
|
+
if (dir !== undefined) nestedGitignore.push({ path, gitignore: `${dir}/.gitignore` });
|
|
691
|
+
}
|
|
614
692
|
continue;
|
|
615
693
|
}
|
|
616
694
|
// `.tf` is read locally so `classifyTerraform` can bundle real content.
|
|
@@ -624,5 +702,5 @@ export function collectCandidates(root: string): RepoFile[] {
|
|
|
624
702
|
const content = readSafe(full);
|
|
625
703
|
if (content !== undefined) files.push({ path, content });
|
|
626
704
|
}
|
|
627
|
-
return files;
|
|
705
|
+
return { files, maxFiles: state.limit, truncated: state.truncated, nestedGitignore };
|
|
628
706
|
}
|
|
@@ -8,9 +8,10 @@ import {
|
|
|
8
8
|
isTerraformStateFile,
|
|
9
9
|
isTerraformStatePath,
|
|
10
10
|
isTerraformWorkDir,
|
|
11
|
+
nestedGitignoreCovering,
|
|
11
12
|
type ScannableFile,
|
|
12
13
|
} from "./terraform-state";
|
|
13
|
-
import { collectCandidates } from "./discover";
|
|
14
|
+
import { collectCandidates, walkCandidates } from "./discover";
|
|
14
15
|
import { RULE_CATALOG, RULE_CATEGORY } from "./catalog";
|
|
15
16
|
|
|
16
17
|
const file = (path: string): ScannableFile => ({ path, content: "" });
|
|
@@ -142,3 +143,87 @@ describe("discovery hands TF023 its paths, and nothing else changes", () => {
|
|
|
142
143
|
rmSync(dir, { recursive: true, force: true });
|
|
143
144
|
});
|
|
144
145
|
});
|
|
146
|
+
|
|
147
|
+
describe("nestedGitignoreCovering (#2528)", () => {
|
|
148
|
+
const reader = (bodies: Record<string, string>) => (dir: string) => bodies[dir];
|
|
149
|
+
|
|
150
|
+
test("finds a .gitignore between the path and the root, matched relative to its own directory", () => {
|
|
151
|
+
expect(nestedGitignoreCovering("infra/prod/terraform.tfstate", reader({ infra: "*.tfstate\n" }))).toBe("infra");
|
|
152
|
+
expect(nestedGitignoreCovering("infra/.terraform", reader({ infra: ".terraform/\n" }))).toBe("infra");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("prefers the nearest one, as git does", () => {
|
|
156
|
+
const bodies = reader({ infra: "*.tfstate\n", "infra/prod": "*.tfstate\n" });
|
|
157
|
+
expect(nestedGitignoreCovering("infra/prod/terraform.tfstate", bodies)).toBe("infra/prod");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("never reads the root's own .gitignore, and ignores one that does not cover the path", () => {
|
|
161
|
+
const bodies = reader({ "": "*.tfstate\n", infra: "*.log\n", other: "*.tfstate\n" });
|
|
162
|
+
expect(nestedGitignoreCovering("infra/terraform.tfstate", bodies)).toBeUndefined();
|
|
163
|
+
expect(nestedGitignoreCovering("terraform.tfstate", bodies)).toBeUndefined();
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("walkCandidates reports what the next release changes, and changes nothing now (#2528)", () => {
|
|
168
|
+
function writeFiles(dir: string, n: number): void {
|
|
169
|
+
mkdirSync(join(dir, "docs"), { recursive: true });
|
|
170
|
+
for (let i = 0; i < n; i++) writeFileSync(join(dir, "docs", `${String(i).padStart(3, "0")}.md`), "x");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
test("a walk that reaches its limit with files left says it was truncated, and keeps the same first files", () => {
|
|
174
|
+
const dir = tmpRepo();
|
|
175
|
+
writeFiles(dir, 5);
|
|
176
|
+
writeFileSync(join(dir, "terraform.tfstate"), "{}");
|
|
177
|
+
const cut = walkCandidates(dir, { maxFiles: 5 });
|
|
178
|
+
expect(cut.truncated).toBe(true);
|
|
179
|
+
expect(cut.maxFiles).toBe(5);
|
|
180
|
+
// `docs/` sorts first, so the state file is the one left behind.
|
|
181
|
+
expect(cut.files).toEqual([]);
|
|
182
|
+
const whole = walkCandidates(dir);
|
|
183
|
+
expect(whole.truncated).toBe(false);
|
|
184
|
+
expect(whole.maxFiles).toBe(1000);
|
|
185
|
+
expect(whole.files.map((f) => f.path)).toEqual(["terraform.tfstate"]);
|
|
186
|
+
rmSync(dir, { recursive: true, force: true });
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("a tree of exactly the limit, or one whose remainder is skipped, is not truncated", () => {
|
|
190
|
+
const dir = tmpRepo();
|
|
191
|
+
writeFiles(dir, 5);
|
|
192
|
+
mkdirSync(join(dir, "node_modules", "x"), { recursive: true });
|
|
193
|
+
writeFileSync(join(dir, "node_modules", "x", "index.js"), "");
|
|
194
|
+
mkdirSync(join(dir, "empty"), { recursive: true });
|
|
195
|
+
expect(walkCandidates(dir, { maxFiles: 5 }).truncated).toBe(false);
|
|
196
|
+
rmSync(dir, { recursive: true, force: true });
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("a state file a nested .gitignore ignores is still a candidate, and is named for the warning", () => {
|
|
200
|
+
const dir = tmpRepo();
|
|
201
|
+
mkdirSync(join(dir, "infra", ".terraform"), { recursive: true });
|
|
202
|
+
writeFileSync(join(dir, "infra", ".gitignore"), "*.tfstate\n.terraform/\n");
|
|
203
|
+
writeFileSync(join(dir, "infra", "terraform.tfstate"), "{}");
|
|
204
|
+
const walk = walkCandidates(dir);
|
|
205
|
+
expect(walk.files.map((f) => f.path).sort()).toEqual(["infra/.terraform", "infra/terraform.tfstate"]);
|
|
206
|
+
expect(auditTerraformState(walk.files)).toHaveLength(2);
|
|
207
|
+
expect(walk.nestedGitignore).toEqual([
|
|
208
|
+
{ path: "infra/.terraform", gitignore: "infra/.gitignore" },
|
|
209
|
+
{ path: "infra/terraform.tfstate", gitignore: "infra/.gitignore" },
|
|
210
|
+
]);
|
|
211
|
+
rmSync(dir, { recursive: true, force: true });
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("nothing is named when the root .gitignore already drops the path, or no nested one covers it", () => {
|
|
215
|
+
const dir = tmpRepo();
|
|
216
|
+
mkdirSync(join(dir, "a"), { recursive: true });
|
|
217
|
+
mkdirSync(join(dir, "b"), { recursive: true });
|
|
218
|
+
writeFileSync(join(dir, ".gitignore"), "*.tfstate.backup\n");
|
|
219
|
+
writeFileSync(join(dir, "a", ".gitignore"), "*.tfstate.backup\n*.log\n");
|
|
220
|
+
writeFileSync(join(dir, "a", "terraform.tfstate"), "{}");
|
|
221
|
+
writeFileSync(join(dir, "a", "terraform.tfstate.backup"), "{}");
|
|
222
|
+
// A sibling's .gitignore is not between `a/` and the root.
|
|
223
|
+
writeFileSync(join(dir, "b", ".gitignore"), "*.tfstate\n");
|
|
224
|
+
const walk = walkCandidates(dir);
|
|
225
|
+
expect(walk.files.map((f) => f.path)).toEqual(["a/terraform.tfstate"]);
|
|
226
|
+
expect(walk.nestedGitignore).toEqual([]);
|
|
227
|
+
rmSync(dir, { recursive: true, force: true });
|
|
228
|
+
});
|
|
229
|
+
});
|
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
* walk that decides which paths reach here is `discover.ts`'s
|
|
23
23
|
* `collectCandidates`, which is also where a locally ignored path is dropped:
|
|
24
24
|
* see `isTerraformStatePath` there and `gitignoreCoversTerraformState` below.
|
|
25
|
+
* Only the root `.gitignore` drops a path today; `nestedGitignoreCovering`
|
|
26
|
+
* finds the ones a nested `.gitignore` would drop from the next release.
|
|
25
27
|
*/
|
|
26
28
|
|
|
27
29
|
import type { AuditFinding } from "./core";
|
|
@@ -83,6 +85,30 @@ export function gitignoreCoversTerraformState(gitignore: string, path: string):
|
|
|
83
85
|
return false;
|
|
84
86
|
}
|
|
85
87
|
|
|
88
|
+
/**
|
|
89
|
+
* The nearest directory strictly between a TF023 path and the scan root whose
|
|
90
|
+
* `.gitignore` covers that path, or undefined. Each `.gitignore` is matched
|
|
91
|
+
* against the path relative to its own directory, the way git scopes it, with
|
|
92
|
+
* the same narrow matcher as the root one. `readGitignore` takes a directory
|
|
93
|
+
* relative to the scan root and returns its `.gitignore` body, if any.
|
|
94
|
+
*
|
|
95
|
+
* The walk does not act on this yet: today only the root `.gitignore` decides
|
|
96
|
+
* TF023, and the audit CLI uses this to warn about the findings that go away
|
|
97
|
+
* once every `.gitignore` is read (#2528).
|
|
98
|
+
*/
|
|
99
|
+
export function nestedGitignoreCovering(
|
|
100
|
+
path: string,
|
|
101
|
+
readGitignore: (dir: string) => string | undefined,
|
|
102
|
+
): string | undefined {
|
|
103
|
+
const parts = path.split("/");
|
|
104
|
+
for (let depth = parts.length - 1; depth > 0; depth--) {
|
|
105
|
+
const dir = parts.slice(0, depth).join("/");
|
|
106
|
+
const body = readGitignore(dir);
|
|
107
|
+
if (body !== undefined && gitignoreCoversTerraformState(body, parts.slice(depth).join("/"))) return dir;
|
|
108
|
+
}
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
86
112
|
/**
|
|
87
113
|
* Report TF023 for every committed state file and `.terraform/` directory in
|
|
88
114
|
* the file list. One finding per path, and one for the `.terraform` directory
|
package/src/cli/command-group.ts
CHANGED
|
@@ -81,11 +81,18 @@ export interface CommandGroup {
|
|
|
81
81
|
* (./conflict-check.ts) treats a collision as a hard, loud failure at
|
|
82
82
|
* plugin-load time rather than a silently-ignored command group. Hand
|
|
83
83
|
* maintained alongside the registry; update both together.
|
|
84
|
+
*
|
|
85
|
+
* `workspace` is reserved before core has the command (#2529, #2524 D12), so
|
|
86
|
+
* no lexicon can claim the word first and later be shadowed by it. Only a
|
|
87
|
+
* command-group name is reserved: a lexicon's own config field or flag
|
|
88
|
+
* called `workspace` (the terraform lexicon's root config has one) is
|
|
89
|
+
* untouched.
|
|
84
90
|
*/
|
|
85
91
|
export const RESERVED_COMMAND_NAMES: ReadonlySet<string> = new Set([
|
|
86
92
|
"build", "lint", "list", "describe", "import", "audit", "migrate", "carve",
|
|
87
93
|
"init", "update", "doctor", "dev", "run", "graph", "vendor", "lifecycle",
|
|
88
94
|
"lc", "components", "emulator", "serve", "operator", "approve",
|
|
95
|
+
"workspace",
|
|
89
96
|
]);
|
|
90
97
|
|
|
91
98
|
/**
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { afterEach, beforeAll, describe, expect, test, vi } from "vitest";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { auditCommand, printAuditResult, type AuditCommandOptions } from "./audit";
|
|
6
|
+
import { loadAuditPlugins } from "../../audit/discover";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* #2528's warning release. `chant audit` warns on stderr about the two changes
|
|
10
|
+
* the next release makes (#2525's exception list), and the report itself stays
|
|
11
|
+
* byte-identical: every test here that runs with a warning also runs the same
|
|
12
|
+
* tree without its cause and compares the JSON.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const NOW = "2026-09-23T00:00:00.000Z";
|
|
16
|
+
const dirs: string[] = [];
|
|
17
|
+
|
|
18
|
+
function tmpRepo(): string {
|
|
19
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-2528-"));
|
|
20
|
+
dirs.push(dir);
|
|
21
|
+
return dir;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
|
26
|
+
vi.restoreAllMocks();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
let plugins: Awaited<ReturnType<typeof loadAuditPlugins>>;
|
|
30
|
+
|
|
31
|
+
// Loading every audit lexicon is the slow part; once, outside any one test's timeout.
|
|
32
|
+
beforeAll(async () => {
|
|
33
|
+
plugins = await loadAuditPlugins();
|
|
34
|
+
}, 120_000);
|
|
35
|
+
|
|
36
|
+
function audit(path: string, extra: Partial<AuditCommandOptions> = {}) {
|
|
37
|
+
return auditCommand({ path, format: "json", now: NOW, toolVersion: "0.0.0", plugins, ...extra });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Print a result the way the CLI does, and split what reached each stream. */
|
|
41
|
+
function printed(result: Awaited<ReturnType<typeof auditCommand>>): { stdout: string; stderr: string } {
|
|
42
|
+
const out: string[] = [];
|
|
43
|
+
const err: string[] = [];
|
|
44
|
+
vi.spyOn(console, "log").mockImplementation((s: string) => void out.push(s));
|
|
45
|
+
vi.spyOn(console, "error").mockImplementation((s: string) => void err.push(s));
|
|
46
|
+
printAuditResult(result);
|
|
47
|
+
vi.restoreAllMocks();
|
|
48
|
+
return { stdout: out.join("\n"), stderr: err.join("\n") };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("a truncated walk is warned about, not yet reported (#2528)", () => {
|
|
52
|
+
function treeWithTail(): string {
|
|
53
|
+
const dir = tmpRepo();
|
|
54
|
+
mkdirSync(join(dir, "a"), { recursive: true });
|
|
55
|
+
for (let i = 0; i < 5; i++) writeFileSync(join(dir, "a", `${i}.md`), "x");
|
|
56
|
+
writeFileSync(join(dir, "a", "terraform.tfstate"), "{}");
|
|
57
|
+
// Past the limit: a state file TF023 would report if the walk reached it.
|
|
58
|
+
mkdirSync(join(dir, "z"), { recursive: true });
|
|
59
|
+
writeFileSync(join(dir, "z", "terraform.tfstate"), "{}");
|
|
60
|
+
return dir;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
test("the warning names the limit and --max-files, on stderr only", async () => {
|
|
64
|
+
const dir = treeWithTail();
|
|
65
|
+
const result = await audit(dir, { maxFiles: 6 });
|
|
66
|
+
expect(result.warnings).toHaveLength(1);
|
|
67
|
+
const { stdout, stderr } = printed(result);
|
|
68
|
+
expect(stdout).toBe(result.output);
|
|
69
|
+
expect(stderr).toContain(`the scan of ${dir} stopped at 6 files, so part of the tree was not audited.`);
|
|
70
|
+
expect(stderr).toContain("Raise it with --max-files <n>.");
|
|
71
|
+
expect(stderr).toContain("From the next release, text and JSON output will also report a truncated scan.");
|
|
72
|
+
expect(result.findings.map((f) => f.file)).toEqual(["a/terraform.tfstate"]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("the JSON and text reports are what the same tree gives without the files past the limit", async () => {
|
|
76
|
+
const dir = treeWithTail();
|
|
77
|
+
const truncatedJson = await audit(dir, { maxFiles: 6 });
|
|
78
|
+
const truncatedText = await audit(dir, { maxFiles: 6, format: "stylish" });
|
|
79
|
+
rmSync(join(dir, "z"), { recursive: true, force: true });
|
|
80
|
+
const wholeJson = await audit(dir, { maxFiles: 6 });
|
|
81
|
+
const wholeText = await audit(dir, { maxFiles: 6, format: "stylish" });
|
|
82
|
+
expect(wholeJson.warnings).toBeUndefined();
|
|
83
|
+
expect(truncatedJson.output).toBe(wholeJson.output);
|
|
84
|
+
expect(truncatedText.output).toBe(wholeText.output);
|
|
85
|
+
expect(printed(truncatedJson).stdout).toBe(printed(wholeJson).stdout);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("--max-files raises the limit, and the default walk does not warn on a small tree", async () => {
|
|
89
|
+
const dir = treeWithTail();
|
|
90
|
+
const raised = await audit(dir, { maxFiles: 7 });
|
|
91
|
+
expect(raised.warnings).toBeUndefined();
|
|
92
|
+
expect(raised.findings.map((f) => f.file).sort()).toEqual(["a/terraform.tfstate", "z/terraform.tfstate"]);
|
|
93
|
+
expect((await audit(dir)).warnings).toBeUndefined();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("TF023 warns when a nested .gitignore would change its finding (#2528)", () => {
|
|
98
|
+
function nestedTree(nested: string | undefined): string {
|
|
99
|
+
const dir = tmpRepo();
|
|
100
|
+
mkdirSync(join(dir, "infra"), { recursive: true });
|
|
101
|
+
writeFileSync(join(dir, ".gitignore"), "node_modules/\n");
|
|
102
|
+
writeFileSync(join(dir, "infra", "main.tf"), 'resource "null_resource" "a" {}\n');
|
|
103
|
+
writeFileSync(join(dir, "infra", "terraform.tfstate"), "{}");
|
|
104
|
+
if (nested !== undefined) writeFileSync(join(dir, "infra", ".gitignore"), nested);
|
|
105
|
+
return dir;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
test("fires for the file a nested .gitignore ignores, naming both, and TF023 still reports it", async () => {
|
|
109
|
+
const dir = nestedTree("*.tfstate\n");
|
|
110
|
+
const result = await audit(dir);
|
|
111
|
+
expect(result.findings.filter((f) => f.checkId === "TF023").map((f) => f.file)).toEqual(["infra/terraform.tfstate"]);
|
|
112
|
+
expect(result.warnings).toEqual([
|
|
113
|
+
{
|
|
114
|
+
file: "infra/terraform.tfstate",
|
|
115
|
+
message: "TF023 reports this path because chant audit reads only the root .gitignore, but infra/.gitignore ignores it.",
|
|
116
|
+
hint: "From the next release chant audit reads every .gitignore between a file and the scan root, and TF023 will no longer report this path.",
|
|
117
|
+
},
|
|
118
|
+
]);
|
|
119
|
+
const { stdout, stderr } = printed(result);
|
|
120
|
+
expect(stdout).toBe(result.output);
|
|
121
|
+
expect(stderr).toContain("infra/terraform.tfstate");
|
|
122
|
+
expect(stderr).toContain("infra/.gitignore ignores it");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("does not fire when the nested .gitignore does not cover the file", async () => {
|
|
126
|
+
const result = await audit(nestedTree("*.log\n"));
|
|
127
|
+
expect(result.findings.some((f) => f.checkId === "TF023")).toBe(true);
|
|
128
|
+
expect(result.warnings).toBeUndefined();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("does not fire when the root .gitignore already drops the file", async () => {
|
|
132
|
+
const dir = nestedTree("*.tfstate\n");
|
|
133
|
+
writeFileSync(join(dir, ".gitignore"), "*.tfstate\n");
|
|
134
|
+
const result = await audit(dir);
|
|
135
|
+
expect(result.findings.some((f) => f.checkId === "TF023")).toBe(false);
|
|
136
|
+
expect(result.warnings).toBeUndefined();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("stdout JSON and the text report are byte-identical with and without the nested .gitignore", async () => {
|
|
140
|
+
const dir = nestedTree("*.tfstate\n");
|
|
141
|
+
const withJson = await audit(dir);
|
|
142
|
+
const withText = await audit(dir, { format: "stylish" });
|
|
143
|
+
const withSarif = await audit(dir, { format: "sarif" });
|
|
144
|
+
rmSync(join(dir, "infra", ".gitignore"));
|
|
145
|
+
const withoutJson = await audit(dir);
|
|
146
|
+
const withoutText = await audit(dir, { format: "stylish" });
|
|
147
|
+
const withoutSarif = await audit(dir, { format: "sarif" });
|
|
148
|
+
expect(withJson.warnings).toHaveLength(1);
|
|
149
|
+
expect(withoutJson.warnings).toBeUndefined();
|
|
150
|
+
expect(withJson.output).toBe(withoutJson.output);
|
|
151
|
+
expect(withText.output).toBe(withoutText.output);
|
|
152
|
+
expect(withSarif.output).toBe(withoutSarif.output);
|
|
153
|
+
expect(printed(withJson).stdout).toBe(printed(withoutJson).stdout);
|
|
154
|
+
JSON.parse(printed(withJson).stdout);
|
|
155
|
+
});
|
|
156
|
+
});
|